authweave-webhooks 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,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: authweave-webhooks
3
+ Version: 7.1.2
4
+ Summary: Asymmetric Standard Webhooks toolkit for AuthWeave integrations
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: authentication,ed25519,security,standard-webhooks,webhooks
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
+ Provides-Extra: httpx
26
+ Requires-Dist: httpx<1.0,>=0.28.1; extra == 'httpx'
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-webhooks
34
+
35
+ Asymmetric Standard Webhooks toolkit for AuthWeave integrations
36
+ (`authweave-standard-webhooks-v1a` Ed25519 profile).
37
+
38
+ This package does **not** depend on `litestar-auth` and is not authentication
39
+ middleware. It verifies or produces webhook deliveries before JSON parsing.
40
+
41
+ ```bash
42
+ uv add 'authweave-webhooks[redis]'
43
+ ```
44
+
45
+ ```python
46
+ from authweave_webhooks import (
47
+ Ed25519PublicKey,
48
+ PublicKeyDocument,
49
+ StandardWebhooksVerifier,
50
+ StaticPublicKeyResolver,
51
+ )
52
+ from authweave_webhooks.redis_store import RedisReplayStore
53
+
54
+ resolver = StaticPublicKeyResolver(
55
+ PublicKeyDocument(
56
+ version="1",
57
+ environment="sandbox",
58
+ owner="merchant-1",
59
+ endpoint="https://merchant.example/hooks/payments",
60
+ not_before=0,
61
+ retire_after=None,
62
+ keys=(Ed25519PublicKey(public_key),),
63
+ )
64
+ )
65
+ verifier = StandardWebhooksVerifier(
66
+ resolver,
67
+ replay_store=RedisReplayStore(redis),
68
+ expected_environment="sandbox",
69
+ expected_owner="merchant-1",
70
+ expected_endpoint="https://merchant.example/hooks/payments",
71
+ time_source=lambda: 1_700_000_000,
72
+ )
73
+ verified = await verifier.verify(headers=headers, body=raw_body)
74
+ ```
75
+
76
+ The replay store is mandatory. After a signature succeeds, `verify()` atomically
77
+ claims the `webhook-id` in a namespace derived from environment, owner, endpoint,
78
+ and id. The library derives a TTL that covers the complete inclusive timestamp
79
+ acceptance window; replay-store outage or capacity pressure fails verification
80
+ closed. A repeated valid delivery is returned with `verified.replay_detected=True`;
81
+ the flag is telemetry, not business idempotency.
82
+
83
+ After **every** successful verification, atomically insert the complete raw body
84
+ and verified metadata into a durable inbox with a unique key over environment,
85
+ owner, endpoint, and `webhook_id`. Never overwrite an existing row, and acknowledge
86
+ the HTTP delivery only after that transaction commits. A retry can then restore an
87
+ inbox row missing after a crash, while a committed row absorbs concurrent or later
88
+ retries. Use a shared replay store such as Redis in multi-worker deployments.
89
+
90
+ Pass an optional core `SecurityObserver` to the verifier or HTTP sender to emit
91
+ bounded verification/replay/delivery telemetry. Retry and queue consumers may
92
+ pass `TraceCorrelation` values through `links=`; trace context is correlation
93
+ only and is never accepted as identity.
94
+
95
+ `HttpxWebhookSender` requires a non-empty exact endpoint allowlist, disables
96
+ redirects, and streams at most 65,536 response bytes. The application must also
97
+ place its HTTP client behind the controlled egress proxy/subnet described in the
98
+ merchant sender threat model; DNS safety is not inferred from HTTPS syntax.
99
+
100
+ ```python
101
+ from authweave_webhooks.sender import HttpxWebhookSender
102
+
103
+ sender = HttpxWebhookSender(
104
+ httpx_client,
105
+ allowed_endpoints={"https://merchant.example/hooks/payments"},
106
+ )
107
+ result = await sender.send(endpoint=merchant_endpoint, delivery=delivery)
108
+ ```
109
+
110
+ ## Extras
111
+
112
+ - `[redis]` — shared `RedisReplayStore` for fail-closed verification
113
+ - `[httpx]` — one-shot HTTPS sender without auto-retry
114
+ - `[litestar]` — raw-body verification helper
115
+
116
+ Private keys stay inside `AsyncMessageSigner` implementations. The library never
117
+ accepts private key bytes on verifier APIs and keeps secrets out of `repr` /
118
+ error messages.
119
+
120
+ See `docs/roadmap.md`, `docs/merchant/webhooks.md`, the sender threat model, and
121
+ ADR 0002 for key-tenancy and egress rules. Language-neutral vectors plus Python
122
+ and dependency-free Node.js verifiers live in `docs/vectors/webhooks/v1a/`.
@@ -0,0 +1,90 @@
1
+ # authweave-webhooks
2
+
3
+ Asymmetric Standard Webhooks toolkit for AuthWeave integrations
4
+ (`authweave-standard-webhooks-v1a` Ed25519 profile).
5
+
6
+ This package does **not** depend on `litestar-auth` and is not authentication
7
+ middleware. It verifies or produces webhook deliveries before JSON parsing.
8
+
9
+ ```bash
10
+ uv add 'authweave-webhooks[redis]'
11
+ ```
12
+
13
+ ```python
14
+ from authweave_webhooks import (
15
+ Ed25519PublicKey,
16
+ PublicKeyDocument,
17
+ StandardWebhooksVerifier,
18
+ StaticPublicKeyResolver,
19
+ )
20
+ from authweave_webhooks.redis_store import RedisReplayStore
21
+
22
+ resolver = StaticPublicKeyResolver(
23
+ PublicKeyDocument(
24
+ version="1",
25
+ environment="sandbox",
26
+ owner="merchant-1",
27
+ endpoint="https://merchant.example/hooks/payments",
28
+ not_before=0,
29
+ retire_after=None,
30
+ keys=(Ed25519PublicKey(public_key),),
31
+ )
32
+ )
33
+ verifier = StandardWebhooksVerifier(
34
+ resolver,
35
+ replay_store=RedisReplayStore(redis),
36
+ expected_environment="sandbox",
37
+ expected_owner="merchant-1",
38
+ expected_endpoint="https://merchant.example/hooks/payments",
39
+ time_source=lambda: 1_700_000_000,
40
+ )
41
+ verified = await verifier.verify(headers=headers, body=raw_body)
42
+ ```
43
+
44
+ The replay store is mandatory. After a signature succeeds, `verify()` atomically
45
+ claims the `webhook-id` in a namespace derived from environment, owner, endpoint,
46
+ and id. The library derives a TTL that covers the complete inclusive timestamp
47
+ acceptance window; replay-store outage or capacity pressure fails verification
48
+ closed. A repeated valid delivery is returned with `verified.replay_detected=True`;
49
+ the flag is telemetry, not business idempotency.
50
+
51
+ After **every** successful verification, atomically insert the complete raw body
52
+ and verified metadata into a durable inbox with a unique key over environment,
53
+ owner, endpoint, and `webhook_id`. Never overwrite an existing row, and acknowledge
54
+ the HTTP delivery only after that transaction commits. A retry can then restore an
55
+ inbox row missing after a crash, while a committed row absorbs concurrent or later
56
+ retries. Use a shared replay store such as Redis in multi-worker deployments.
57
+
58
+ Pass an optional core `SecurityObserver` to the verifier or HTTP sender to emit
59
+ bounded verification/replay/delivery telemetry. Retry and queue consumers may
60
+ pass `TraceCorrelation` values through `links=`; trace context is correlation
61
+ only and is never accepted as identity.
62
+
63
+ `HttpxWebhookSender` requires a non-empty exact endpoint allowlist, disables
64
+ redirects, and streams at most 65,536 response bytes. The application must also
65
+ place its HTTP client behind the controlled egress proxy/subnet described in the
66
+ merchant sender threat model; DNS safety is not inferred from HTTPS syntax.
67
+
68
+ ```python
69
+ from authweave_webhooks.sender import HttpxWebhookSender
70
+
71
+ sender = HttpxWebhookSender(
72
+ httpx_client,
73
+ allowed_endpoints={"https://merchant.example/hooks/payments"},
74
+ )
75
+ result = await sender.send(endpoint=merchant_endpoint, delivery=delivery)
76
+ ```
77
+
78
+ ## Extras
79
+
80
+ - `[redis]` — shared `RedisReplayStore` for fail-closed verification
81
+ - `[httpx]` — one-shot HTTPS sender without auto-retry
82
+ - `[litestar]` — raw-body verification helper
83
+
84
+ Private keys stay inside `AsyncMessageSigner` implementations. The library never
85
+ accepts private key bytes on verifier APIs and keeps secrets out of `repr` /
86
+ error messages.
87
+
88
+ See `docs/roadmap.md`, `docs/merchant/webhooks.md`, the sender threat model, and
89
+ ADR 0002 for key-tenancy and egress rules. Language-neutral vectors plus Python
90
+ and dependency-free Node.js verifiers live in `docs/vectors/webhooks/v1a/`.
@@ -0,0 +1,52 @@
1
+ """Asymmetric Standard Webhooks toolkit for AuthWeave integrations."""
2
+
3
+ from authweave_webhooks.errors import WebhookFailureCode, WebhookVerificationError
4
+ from authweave_webhooks.headers import build_signing_input, format_signature_header, parse_headers
5
+ from authweave_webhooks.keys import PublicKeyResolver, StaticPublicKeyResolver
6
+ from authweave_webhooks.models import (
7
+ DEFAULT_TIMESTAMP_TOLERANCE_SECONDS,
8
+ HEADER_ID,
9
+ HEADER_SIGNATURE,
10
+ HEADER_TIMESTAMP,
11
+ MAX_BODY_BYTES,
12
+ MAX_PUBLIC_KEYS,
13
+ MAX_SIGNATURES,
14
+ MAX_WEBHOOK_ID_LENGTH,
15
+ SIGNATURE_VERSION,
16
+ Ed25519PublicKey,
17
+ PublicKeyDocument,
18
+ VerifiedWebhook,
19
+ WebhookDelivery,
20
+ )
21
+ from authweave_webhooks.signer import AsyncMessageSigner, LocalEd25519KeyringSigner, create_delivery
22
+ from authweave_webhooks.verify import StandardWebhooksVerifier
23
+
24
+ __version__ = "7.1.2"
25
+
26
+ __all__ = (
27
+ "DEFAULT_TIMESTAMP_TOLERANCE_SECONDS",
28
+ "HEADER_ID",
29
+ "HEADER_SIGNATURE",
30
+ "HEADER_TIMESTAMP",
31
+ "MAX_BODY_BYTES",
32
+ "MAX_PUBLIC_KEYS",
33
+ "MAX_SIGNATURES",
34
+ "MAX_WEBHOOK_ID_LENGTH",
35
+ "SIGNATURE_VERSION",
36
+ "AsyncMessageSigner",
37
+ "Ed25519PublicKey",
38
+ "LocalEd25519KeyringSigner",
39
+ "PublicKeyDocument",
40
+ "PublicKeyResolver",
41
+ "StandardWebhooksVerifier",
42
+ "StaticPublicKeyResolver",
43
+ "VerifiedWebhook",
44
+ "WebhookDelivery",
45
+ "WebhookFailureCode",
46
+ "WebhookVerificationError",
47
+ "__version__",
48
+ "build_signing_input",
49
+ "create_delivery",
50
+ "format_signature_header",
51
+ "parse_headers",
52
+ )
@@ -0,0 +1,34 @@
1
+ """Typed errors and outcomes for Standard Webhooks verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+
8
+ class WebhookFailureCode(StrEnum):
9
+ """Bounded failure codes returned to callers without key-discrimination detail."""
10
+
11
+ MALFORMED_HEADERS = "malformed_headers"
12
+ DUPLICATE_HEADER = "duplicate_header"
13
+ BODY_TOO_LARGE = "body_too_large"
14
+ TIMESTAMP_INVALID = "timestamp_invalid"
15
+ TIMESTAMP_OUT_OF_TOLERANCE = "timestamp_out_of_tolerance"
16
+ SIGNATURE_INVALID = "signature_invalid"
17
+ KEY_UNAVAILABLE = "key_unavailable"
18
+ ENVIRONMENT_MISMATCH = "environment_mismatch"
19
+ OWNER_MISMATCH = "owner_mismatch"
20
+ ENDPOINT_MISMATCH = "endpoint_mismatch"
21
+ STORE_UNAVAILABLE = "store_unavailable"
22
+
23
+
24
+ class WebhookVerificationError(Exception):
25
+ """Fail-closed verification error with a secret-free bounded code."""
26
+
27
+ def __init__(self, code: WebhookFailureCode) -> None:
28
+ """Bind the public failure code without embedding raw material."""
29
+ self.code = code
30
+ super().__init__(code.value)
31
+
32
+ def __repr__(self) -> str:
33
+ """Return a secret-free representation."""
34
+ return f"WebhookVerificationError(code={self.code!r})"
@@ -0,0 +1,135 @@
1
+ """Bounded Standard Webhooks header and signing-input helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import re
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass, field
9
+
10
+ from authweave_webhooks.errors import WebhookFailureCode, WebhookVerificationError
11
+ from authweave_webhooks.models import (
12
+ ED25519_SIGNATURE_BYTES,
13
+ HEADER_ID,
14
+ HEADER_SIGNATURE,
15
+ HEADER_TIMESTAMP,
16
+ MAX_BODY_BYTES,
17
+ MAX_SIGNATURES,
18
+ MAX_WEBHOOK_ID_LENGTH,
19
+ SIGNATURE_VERSION,
20
+ )
21
+
22
+ _ID_PATTERN = re.compile(r"^[A-Za-z0-9_-]+$")
23
+ _TIMESTAMP_PATTERN = re.compile(r"^[0-9]+$")
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class ParsedWebhookHeaders:
28
+ """Exactly one id, timestamp, and signature list after duplicate rejection."""
29
+
30
+ webhook_id: str
31
+ timestamp: int
32
+ signatures: tuple[bytes, ...] = field(repr=False)
33
+
34
+
35
+ def build_signing_input(*, webhook_id: str, timestamp: int, body: bytes) -> bytes:
36
+ """Build the Standard Webhooks signed byte sequence.
37
+
38
+ Returns:
39
+ ASCII ``webhook-id.timestamp.`` concatenated with the exact raw body.
40
+ """
41
+ prefix = f"{webhook_id}.{timestamp}.".encode("ascii")
42
+ return prefix + body
43
+
44
+
45
+ def parse_headers(
46
+ headers: Mapping[str, str] | list[tuple[str, str]],
47
+ *,
48
+ body: bytes,
49
+ max_body_bytes: int = MAX_BODY_BYTES,
50
+ ) -> ParsedWebhookHeaders:
51
+ """Parse and bound Standard Webhooks headers before cryptographic verify.
52
+
53
+ Returns:
54
+ The parsed header triple.
55
+
56
+ Raises:
57
+ WebhookVerificationError: On duplicate, malformed, or oversized input.
58
+ """
59
+ if len(body) > max_body_bytes:
60
+ raise WebhookVerificationError(WebhookFailureCode.BODY_TOO_LARGE)
61
+
62
+ values = _collect_headers(headers)
63
+ webhook_id = _require_one(values, HEADER_ID)
64
+ timestamp_raw = _require_one(values, HEADER_TIMESTAMP)
65
+ signature_raw = _require_one(values, HEADER_SIGNATURE)
66
+
67
+ if (
68
+ not webhook_id
69
+ or len(webhook_id) > MAX_WEBHOOK_ID_LENGTH
70
+ or "." in webhook_id
71
+ or _ID_PATTERN.fullmatch(webhook_id) is None
72
+ ):
73
+ raise WebhookVerificationError(WebhookFailureCode.MALFORMED_HEADERS)
74
+ if not timestamp_raw or "." in timestamp_raw or _TIMESTAMP_PATTERN.fullmatch(timestamp_raw) is None:
75
+ raise WebhookVerificationError(WebhookFailureCode.TIMESTAMP_INVALID)
76
+
77
+ signatures = _parse_signatures(signature_raw)
78
+ return ParsedWebhookHeaders(webhook_id=webhook_id, timestamp=int(timestamp_raw), signatures=signatures)
79
+
80
+
81
+ def format_signature_header(signatures: list[bytes]) -> str:
82
+ """Format one or more Ed25519 signatures as a Standard Webhooks header.
83
+
84
+ Returns:
85
+ A space-delimited ``v1a,<base64>`` list.
86
+ """
87
+ if not signatures or len(signatures) > MAX_SIGNATURES:
88
+ msg = f"signature count must be 1..{MAX_SIGNATURES}"
89
+ raise ValueError(msg)
90
+ parts: list[str] = []
91
+ for signature in signatures:
92
+ if len(signature) != ED25519_SIGNATURE_BYTES:
93
+ msg = "Ed25519 signature must be 64 bytes"
94
+ raise ValueError(msg)
95
+ parts.append(f"{SIGNATURE_VERSION},{base64.b64encode(signature).decode('ascii')}")
96
+ return " ".join(parts)
97
+
98
+
99
+ def _collect_headers(headers: Mapping[str, str] | list[tuple[str, str]]) -> dict[str, list[str]]:
100
+ items = headers.items() if not isinstance(headers, list) else headers
101
+ collected: dict[str, list[str]] = {}
102
+ for raw_name, raw_value in items:
103
+ name = raw_name.lower()
104
+ if name not in {HEADER_ID, HEADER_TIMESTAMP, HEADER_SIGNATURE}:
105
+ continue
106
+ collected.setdefault(name, []).append(raw_value)
107
+ return collected
108
+
109
+
110
+ def _require_one(values: dict[str, list[str]], name: str) -> str:
111
+ present = values.get(name, [])
112
+ if len(present) > 1:
113
+ raise WebhookVerificationError(WebhookFailureCode.DUPLICATE_HEADER)
114
+ if len(present) != 1:
115
+ raise WebhookVerificationError(WebhookFailureCode.MALFORMED_HEADERS)
116
+ return present[0]
117
+
118
+
119
+ def _parse_signatures(raw: str) -> tuple[bytes, ...]:
120
+ parts = raw.split()
121
+ if not parts or len(parts) > MAX_SIGNATURES:
122
+ raise WebhookVerificationError(WebhookFailureCode.SIGNATURE_INVALID)
123
+ signatures: list[bytes] = []
124
+ for part in parts:
125
+ version, sep, payload = part.partition(",")
126
+ if sep != "," or version != SIGNATURE_VERSION or not payload:
127
+ raise WebhookVerificationError(WebhookFailureCode.SIGNATURE_INVALID)
128
+ try:
129
+ decoded = base64.b64decode(payload, validate=True)
130
+ except ValueError as exc:
131
+ raise WebhookVerificationError(WebhookFailureCode.SIGNATURE_INVALID) from exc
132
+ if len(decoded) != ED25519_SIGNATURE_BYTES:
133
+ raise WebhookVerificationError(WebhookFailureCode.SIGNATURE_INVALID)
134
+ signatures.append(decoded)
135
+ return tuple(signatures)
@@ -0,0 +1,70 @@
1
+ """Public-key resolver protocol and static onboarding documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+
7
+ from authweave_webhooks.models import PublicKeyDocument
8
+
9
+
10
+ class PublicKeyResolver(Protocol):
11
+ """Resolve and optionally refresh the trusted public-key document."""
12
+
13
+ async def resolve(self) -> PublicKeyDocument:
14
+ """Return the currently trusted key document.
15
+
16
+ Returns:
17
+ The onboarding-trusted public-key document.
18
+ """
19
+ ...
20
+
21
+ async def refresh(self) -> PublicKeyDocument | None:
22
+ """Perform at most one controlled refresh when verification misses.
23
+
24
+ Returns:
25
+ The latest trusted document, or ``None`` when refresh is
26
+ unavailable. Concurrent callers may receive a document refreshed by
27
+ another caller without performing a second remote refresh.
28
+ """
29
+ ...
30
+
31
+
32
+ class StaticPublicKeyResolver:
33
+ """In-memory resolver for tests and single-document onboarding fixtures."""
34
+
35
+ __slots__ = ("_document", "_refresh_document", "_refreshed")
36
+
37
+ def __init__(
38
+ self,
39
+ document: PublicKeyDocument,
40
+ *,
41
+ refresh_document: PublicKeyDocument | None = None,
42
+ ) -> None:
43
+ """Bind the active document and an optional one-shot refresh document."""
44
+ self._document = document
45
+ self._refresh_document = refresh_document
46
+ self._refreshed = False
47
+
48
+ async def resolve(self) -> PublicKeyDocument:
49
+ """Return the current document.
50
+
51
+ Returns:
52
+ The trusted public-key document.
53
+ """
54
+ return self._document
55
+
56
+ async def refresh(self) -> PublicKeyDocument | None:
57
+ """Replace the document once from the configured refresh snapshot.
58
+
59
+ Returns:
60
+ The refreshed/current document, or ``None`` when no refresh snapshot
61
+ is configured. Only the first caller changes the snapshot; later
62
+ concurrent callers receive the same refreshed document.
63
+ """
64
+ if self._refreshed:
65
+ return self._document
66
+ if self._refresh_document is None:
67
+ return None
68
+ self._refreshed = True
69
+ self._document = self._refresh_document
70
+ return self._document
@@ -0,0 +1,31 @@
1
+ """Optional Litestar raw-body adapter for webhook verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol
6
+
7
+ if TYPE_CHECKING:
8
+ from collections.abc import Mapping
9
+
10
+ from authweave_webhooks.models import VerifiedWebhook
11
+ from authweave_webhooks.verify import StandardWebhooksVerifier
12
+
13
+
14
+ class _LitestarRequest(Protocol):
15
+ headers: Mapping[str, str]
16
+
17
+ async def body(self) -> bytes: ...
18
+
19
+
20
+ async def verify_litestar_request(
21
+ verifier: StandardWebhooksVerifier,
22
+ request: _LitestarRequest,
23
+ ) -> VerifiedWebhook:
24
+ """Verify a Litestar request using exact raw body bytes before deserialization.
25
+
26
+ Returns:
27
+ The verified webhook envelope.
28
+ """
29
+ body = await request.body()
30
+ headers = list(request.headers.items())
31
+ return await verifier.verify(headers=headers, body=bytes(body))
@@ -0,0 +1,115 @@
1
+ """Immutable webhook models and key-document contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Final
7
+
8
+ ED25519_PUBLIC_KEY_BYTES = 32
9
+ ED25519_SIGNATURE_BYTES = 64
10
+ MAX_BODY_BYTES: Final = 262_144
11
+ MAX_WEBHOOK_ID_LENGTH: Final = 128
12
+ MAX_SIGNATURES: Final = 3
13
+ MAX_PUBLIC_KEYS: Final = 3
14
+ DEFAULT_TIMESTAMP_TOLERANCE_SECONDS: Final = 300
15
+ SIGNATURE_VERSION: Final = "v1a"
16
+ HEADER_ID: Final = "webhook-id"
17
+ HEADER_TIMESTAMP: Final = "webhook-timestamp"
18
+ HEADER_SIGNATURE: Final = "webhook-signature"
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Ed25519PublicKey:
23
+ """One onboarding-trusted Ed25519 public key (32 raw bytes)."""
24
+
25
+ public_key: bytes
26
+
27
+ def __post_init__(self) -> None:
28
+ """Reject non-Ed25519 key material."""
29
+ if len(self.public_key) != ED25519_PUBLIC_KEY_BYTES:
30
+ msg = "Ed25519 public key must be 32 bytes"
31
+ raise ValueError(msg)
32
+
33
+ def __repr__(self) -> str:
34
+ """Omit raw key bytes from representations."""
35
+ return "Ed25519PublicKey(public_key=...)"
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class PublicKeyDocument:
40
+ """Bounded public-key set for one merchant/endpoint environment.
41
+
42
+ At most three keys cover active/next/retiring rotation overlap. Private keys
43
+ never appear in this document.
44
+ """
45
+
46
+ version: str
47
+ environment: str
48
+ owner: str
49
+ endpoint: str
50
+ not_before: int
51
+ retire_after: int | None
52
+ keys: tuple[Ed25519PublicKey, ...]
53
+
54
+ def __post_init__(self) -> None:
55
+ """Enforce rotation cardinality and identity bounds."""
56
+ if not self.version or not self.environment or not self.owner or not self.endpoint:
57
+ msg = "version, environment, owner, and endpoint are required"
58
+ raise ValueError(msg)
59
+ if not self.keys or len(self.keys) > MAX_PUBLIC_KEYS:
60
+ msg = f"public key document must contain 1..{MAX_PUBLIC_KEYS} keys"
61
+ raise ValueError(msg)
62
+ if self.retire_after is not None and self.retire_after < self.not_before:
63
+ msg = "retire_after must be >= not_before"
64
+ raise ValueError(msg)
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class VerifiedWebhook:
69
+ """Immutable verification result produced before JSON parsing."""
70
+
71
+ webhook_id: str
72
+ timestamp: int
73
+ body: bytes
74
+ environment: str
75
+ owner: str
76
+ endpoint: str
77
+ key_document_version: str
78
+ replay_detected: bool = False
79
+
80
+ def __repr__(self) -> str:
81
+ """Omit raw body bytes from representations."""
82
+ return (
83
+ "VerifiedWebhook("
84
+ f"webhook_id={self.webhook_id!r}, timestamp={self.timestamp}, "
85
+ f"body_len={len(self.body)}, environment={self.environment!r}, "
86
+ f"owner={self.owner!r}, endpoint={self.endpoint!r}, "
87
+ f"key_document_version={self.key_document_version!r}, "
88
+ f"replay_detected={self.replay_detected!r})"
89
+ )
90
+
91
+
92
+ @dataclass(frozen=True, slots=True)
93
+ class WebhookDelivery:
94
+ """Producer output: exact headers and raw body for one delivery attempt."""
95
+
96
+ webhook_id: str
97
+ timestamp: int
98
+ body: bytes
99
+ signature_header: str
100
+
101
+ def headers(self) -> dict[str, str]:
102
+ """Return the three Standard Webhooks headers for this attempt."""
103
+ return {
104
+ HEADER_ID: self.webhook_id,
105
+ HEADER_TIMESTAMP: str(self.timestamp),
106
+ HEADER_SIGNATURE: self.signature_header,
107
+ }
108
+
109
+ def __repr__(self) -> str:
110
+ """Omit raw body and signature material from representations."""
111
+ return (
112
+ "WebhookDelivery("
113
+ f"webhook_id={self.webhook_id!r}, timestamp={self.timestamp}, "
114
+ f"body_len={len(self.body)}, signature_header=...)"
115
+ )
File without changes
@@ -0,0 +1,35 @@
1
+ """Optional Redis-backed webhook replay store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from authweave_core import ReplayOutcome, validate_replay_key
6
+
7
+
8
+ class RedisReplayStore:
9
+ """Thin Redis SET NX EX adapter satisfying authweave-core ``ReplayStore``.
10
+
11
+ Requires the ``authweave-webhooks[redis]`` extra. Connection failures map to
12
+ ``Unavailable`` so webhook verification fails closed.
13
+ """
14
+
15
+ __slots__ = ("_redis",)
16
+
17
+ def __init__(self, redis: object) -> None:
18
+ """Bind an async Redis client exposing ``set(name, value, nx=True, ex=...)``."""
19
+ self._redis = redis
20
+
21
+ async def check_and_store(self, key: str, *, ttl_seconds: float) -> ReplayOutcome:
22
+ """Atomically claim ``key`` with a TTL.
23
+
24
+ Returns:
25
+ The typed put-if-absent outcome.
26
+ """
27
+ validate_replay_key(key)
28
+ if ttl_seconds <= 0:
29
+ msg = "ttl_seconds must be positive"
30
+ raise ValueError(msg)
31
+ try:
32
+ created = await self._redis.set(key, "1", nx=True, ex=int(ttl_seconds)) # ty: ignore[unresolved-attribute]
33
+ except Exception: # ruff: ignore[blind-except] - any client/transport failure is Unavailable
34
+ return ReplayOutcome.UNAVAILABLE
35
+ return ReplayOutcome.STORED if created else ReplayOutcome.REPLAY
@@ -0,0 +1,155 @@
1
+ """Optional bounded HTTPS webhook sender (one attempt, no auto-retry)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+ from urllib.parse import urlparse
8
+
9
+ from authweave_core import SecurityOperation, SecurityOutcome, observe_security
10
+
11
+ from authweave_webhooks.models import HEADER_ID, HEADER_SIGNATURE, HEADER_TIMESTAMP, WebhookDelivery
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Collection, Sequence
15
+
16
+ from authweave_core import SecurityObserver, TraceCorrelation
17
+
18
+ _MAX_RESPONSE_BYTES = 65_536
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class SenderResult:
23
+ """Outcome of a single webhook POST attempt."""
24
+
25
+ status_code: int
26
+ retry_after_seconds: int | None
27
+ body: bytes
28
+
29
+ def __repr__(self) -> str:
30
+ """Omit response body contents from representations.
31
+
32
+ Returns:
33
+ A secret-free summary.
34
+ """
35
+ return (
36
+ f"SenderResult(status_code={self.status_code}, "
37
+ f"retry_after_seconds={self.retry_after_seconds}, body_len={len(self.body)})"
38
+ )
39
+
40
+
41
+ def validate_https_endpoint(url: str) -> str:
42
+ """Accept only explicit HTTPS endpoints without userinfo or fragment.
43
+
44
+ Returns:
45
+ The validated URL.
46
+
47
+ Raises:
48
+ ValueError: If the URL is not an allowed HTTPS endpoint.
49
+ """
50
+ parsed = urlparse(url)
51
+ if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
52
+ msg = "webhook endpoint must be HTTPS without userinfo or fragment"
53
+ raise ValueError(msg)
54
+ return url
55
+
56
+
57
+ class HttpxWebhookSender:
58
+ """Send one bounded HTTPS delivery attempt.
59
+
60
+ Does not retry automatically. ``Retry-After`` is returned as a typed
61
+ recommendation only. Every destination must match the constructor's exact
62
+ onboarding allowlist. A controlled egress proxy/subnet is still required to
63
+ contain DNS rebinding and compromised allowlisted destinations.
64
+ """
65
+
66
+ __slots__ = ("_allowed_endpoints", "_client", "_observer", "_timeout_seconds")
67
+
68
+ def __init__(
69
+ self,
70
+ client: object,
71
+ *,
72
+ allowed_endpoints: Collection[str],
73
+ timeout_seconds: float = 5.0,
74
+ observer: SecurityObserver | None = None,
75
+ ) -> None:
76
+ """Bind an httpx-like async client and exact onboarding endpoints."""
77
+ if timeout_seconds <= 0:
78
+ msg = "timeout_seconds must be positive"
79
+ raise ValueError(msg)
80
+ if not allowed_endpoints:
81
+ msg = "allowed_endpoints must contain at least one endpoint"
82
+ raise ValueError(msg)
83
+ self._client = client
84
+ self._allowed_endpoints = frozenset(validate_https_endpoint(endpoint) for endpoint in allowed_endpoints)
85
+ self._timeout_seconds = timeout_seconds
86
+ self._observer = observer
87
+
88
+ async def send(
89
+ self,
90
+ *,
91
+ endpoint: str,
92
+ delivery: WebhookDelivery,
93
+ links: Sequence[TraceCorrelation] = (),
94
+ ) -> SenderResult:
95
+ """POST one delivery attempt to ``endpoint``.
96
+
97
+ Returns:
98
+ Status, optional Retry-After recommendation, and a bounded body.
99
+
100
+ Raises:
101
+ ValueError: If the endpoint fails the HTTPS policy or exact
102
+ onboarding allowlist.
103
+ """
104
+ with observe_security(
105
+ self._observer,
106
+ SecurityOperation.WEBHOOK_DELIVER,
107
+ profile="standard_webhooks",
108
+ links=links,
109
+ ) as observation:
110
+ validated_endpoint = validate_https_endpoint(endpoint)
111
+ if validated_endpoint not in self._allowed_endpoints:
112
+ msg = "webhook endpoint is not in the configured exact allowlist"
113
+ raise ValueError(msg)
114
+ async with self._client.stream( # ty: ignore[unresolved-attribute]
115
+ "POST",
116
+ validated_endpoint,
117
+ content=delivery.body,
118
+ headers={
119
+ HEADER_ID: delivery.webhook_id,
120
+ HEADER_TIMESTAMP: str(delivery.timestamp),
121
+ HEADER_SIGNATURE: delivery.signature_header,
122
+ "content-type": "application/json",
123
+ },
124
+ timeout=self._timeout_seconds,
125
+ follow_redirects=False,
126
+ ) as response:
127
+ body = await _read_bounded_body(response)
128
+ retry_after = _parse_retry_after(response.headers.get("retry-after"))
129
+ status_code = int(response.status_code)
130
+ result = SenderResult(status_code=status_code, retry_after_seconds=retry_after, body=body)
131
+ observation.set_outcome(
132
+ SecurityOutcome.SUCCESS if 200 <= result.status_code < 300 else SecurityOutcome.ERROR,
133
+ )
134
+ return result
135
+
136
+
137
+ async def _read_bounded_body(response: object) -> bytes:
138
+ body = bytearray()
139
+ async for raw_chunk in response.aiter_bytes(): # ty: ignore[unresolved-attribute]
140
+ chunk = bytes(raw_chunk)
141
+ remaining = _MAX_RESPONSE_BYTES - len(body)
142
+ body.extend(chunk[:remaining])
143
+ if len(body) == _MAX_RESPONSE_BYTES:
144
+ break
145
+ return bytes(body)
146
+
147
+
148
+ def _parse_retry_after(raw: str | None) -> int | None:
149
+ if raw is None:
150
+ return None
151
+ try:
152
+ value = int(raw)
153
+ except ValueError:
154
+ return None
155
+ return value if value >= 0 else None
@@ -0,0 +1,101 @@
1
+ """Ed25519 signing protocol and local keyring signer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+
7
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
8
+
9
+ from authweave_webhooks.headers import build_signing_input, format_signature_header
10
+ from authweave_webhooks.models import WebhookDelivery
11
+
12
+
13
+ class AsyncMessageSigner(Protocol):
14
+ """Sign bounded webhook bytes using an approved key reference.
15
+
16
+ Implementations must accept a key reference, never raw private key bytes, in
17
+ the public call signature. Private material stays inside the signer/KMS.
18
+ """
19
+
20
+ async def sign(self, *, key_ref: str, message: bytes) -> bytes:
21
+ """Return a 64-byte Ed25519 signature over ``message``.
22
+
23
+ Returns:
24
+ The raw Ed25519 signature bytes.
25
+ """
26
+ ...
27
+
28
+
29
+ class LocalEd25519KeyringSigner:
30
+ """Reference signer that looks up private keys by opaque key reference.
31
+
32
+ Intended for tests and single-process demos. Production deployments should
33
+ use a KMS/HSM-backed ``AsyncMessageSigner``. Private keys never appear in
34
+ ``repr`` or raised error messages.
35
+ """
36
+
37
+ __slots__ = ("_keys",)
38
+
39
+ def __init__(self, keys: dict[str, Ed25519PrivateKey]) -> None:
40
+ """Bind opaque key references to private keys held only in memory."""
41
+ if not keys:
42
+ msg = "keyring must contain at least one key"
43
+ raise ValueError(msg)
44
+ self._keys = dict(keys)
45
+
46
+ def __repr__(self) -> str:
47
+ """List key references without private material."""
48
+ refs = sorted(self._keys)
49
+ return f"LocalEd25519KeyringSigner(key_refs={refs!r})"
50
+
51
+ async def sign(self, *, key_ref: str, message: bytes) -> bytes:
52
+ """Sign ``message`` with the referenced key.
53
+
54
+ Returns:
55
+ The raw Ed25519 signature.
56
+
57
+ Raises:
58
+ KeyError: If the key reference is unknown.
59
+ """
60
+ try:
61
+ private_key = self._keys[key_ref]
62
+ except KeyError as exc:
63
+ msg = "unknown key reference"
64
+ raise KeyError(msg) from exc
65
+ return private_key.sign(message)
66
+
67
+
68
+ async def create_delivery(
69
+ *,
70
+ signer: AsyncMessageSigner,
71
+ key_refs: list[str],
72
+ webhook_id: str,
73
+ timestamp: int,
74
+ body: bytes,
75
+ ) -> WebhookDelivery:
76
+ """Construct one Standard Webhooks delivery attempt.
77
+
78
+ ``webhook_id`` must be stable across retries; ``timestamp`` is the attempt
79
+ time and should be refreshed on retry. ``key_refs`` may include active and
80
+ next keys during rotation overlap.
81
+
82
+ Returns:
83
+ Headers-ready delivery material.
84
+
85
+ Raises:
86
+ ValueError: If identifiers are malformed.
87
+ """
88
+ if not webhook_id or "." in webhook_id:
89
+ msg = "webhook_id must be non-empty and must not contain '.'"
90
+ raise ValueError(msg)
91
+ if timestamp < 0:
92
+ msg = "timestamp must be non-negative"
93
+ raise ValueError(msg)
94
+ message = build_signing_input(webhook_id=webhook_id, timestamp=timestamp, body=body)
95
+ signatures = [await signer.sign(key_ref=key_ref, message=message) for key_ref in key_refs]
96
+ return WebhookDelivery(
97
+ webhook_id=webhook_id,
98
+ timestamp=timestamp,
99
+ body=body,
100
+ signature_header=format_signature_header(signatures),
101
+ )
@@ -0,0 +1,240 @@
1
+ """Standard Webhooks Ed25519 verifier."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from hashlib import sha256
7
+ from json import dumps
8
+ from typing import TYPE_CHECKING
9
+
10
+ from authweave_core import ReplayOutcome, SecurityOperation, SecurityOutcome, observe_security
11
+ from cryptography.exceptions import InvalidSignature
12
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
13
+
14
+ from authweave_webhooks.errors import WebhookFailureCode, WebhookVerificationError
15
+ from authweave_webhooks.headers import build_signing_input, parse_headers
16
+ from authweave_webhooks.models import (
17
+ DEFAULT_TIMESTAMP_TOLERANCE_SECONDS,
18
+ MAX_BODY_BYTES,
19
+ VerifiedWebhook,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Callable, Mapping, Sequence
24
+
25
+ from authweave_core import ReplayStore, SecurityObserver, TraceCorrelation
26
+
27
+ from authweave_webhooks.keys import PublicKeyResolver
28
+ from authweave_webhooks.models import PublicKeyDocument
29
+
30
+
31
+ class StandardWebhooksVerifier:
32
+ """Verify asymmetric Standard Webhooks deliveries before JSON parsing."""
33
+
34
+ __slots__ = (
35
+ "_expected_endpoint",
36
+ "_expected_environment",
37
+ "_expected_owner",
38
+ "_max_body_bytes",
39
+ "_observer",
40
+ "_replay_store",
41
+ "_replay_ttl_seconds",
42
+ "_resolver",
43
+ "_time_source",
44
+ "_tolerance",
45
+ )
46
+
47
+ def __init__(
48
+ self,
49
+ resolver: PublicKeyResolver,
50
+ *,
51
+ replay_store: ReplayStore,
52
+ expected_environment: str,
53
+ expected_owner: str,
54
+ expected_endpoint: str,
55
+ timestamp_tolerance_seconds: int = DEFAULT_TIMESTAMP_TOLERANCE_SECONDS,
56
+ max_body_bytes: int = MAX_BODY_BYTES,
57
+ time_source: Callable[[], int],
58
+ observer: SecurityObserver | None = None,
59
+ ) -> None:
60
+ """Bind onboarding identity, clock, key resolver, and replay store."""
61
+ if timestamp_tolerance_seconds <= 0:
62
+ msg = "timestamp_tolerance_seconds must be positive"
63
+ raise ValueError(msg)
64
+ self._resolver = resolver
65
+ self._replay_store = replay_store
66
+ self._expected_environment = expected_environment
67
+ self._expected_owner = expected_owner
68
+ self._expected_endpoint = expected_endpoint
69
+ self._tolerance = timestamp_tolerance_seconds
70
+ # Both timestamp boundaries are inclusive; cover a proof first seen at the future boundary.
71
+ self._replay_ttl_seconds = timestamp_tolerance_seconds * 2 + 1
72
+ self._max_body_bytes = max_body_bytes
73
+ self._time_source = time_source
74
+ self._observer = observer
75
+
76
+ async def verify(
77
+ self,
78
+ *,
79
+ headers: Mapping[str, str] | list[tuple[str, str]],
80
+ body: bytes,
81
+ links: Sequence[TraceCorrelation] = (),
82
+ ) -> VerifiedWebhook:
83
+ """Verify headers and body, refreshing keys at most once on miss.
84
+
85
+ Returns:
86
+ An immutable verified webhook envelope.
87
+
88
+ Raises:
89
+ WebhookVerificationError: On any fail-closed verification failure.
90
+ """
91
+ with observe_security(
92
+ self._observer,
93
+ SecurityOperation.VERIFY_WEBHOOK,
94
+ profile="standard_webhooks",
95
+ credential_kind="ed25519",
96
+ links=links,
97
+ ) as observation:
98
+ try:
99
+ result = await self._verify(headers=headers, body=body)
100
+ result = replace(
101
+ result,
102
+ replay_detected=await self._claim_replay(result, links=links),
103
+ )
104
+ except WebhookVerificationError as exc:
105
+ outcome = (
106
+ SecurityOutcome.UNAVAILABLE
107
+ if exc.code in {WebhookFailureCode.KEY_UNAVAILABLE, WebhookFailureCode.STORE_UNAVAILABLE}
108
+ else SecurityOutcome.INVALID
109
+ )
110
+ observation.set_outcome(outcome, reason_code=exc.code.value)
111
+ raise
112
+ observation.set_outcome(SecurityOutcome.VERIFIED)
113
+ return result
114
+
115
+ async def _verify(
116
+ self,
117
+ *,
118
+ headers: Mapping[str, str] | list[tuple[str, str]],
119
+ body: bytes,
120
+ ) -> VerifiedWebhook:
121
+ parsed = parse_headers(
122
+ headers if isinstance(headers, list) else dict(headers),
123
+ body=body,
124
+ max_body_bytes=self._max_body_bytes,
125
+ )
126
+ self._check_timestamp(parsed.timestamp)
127
+ message = build_signing_input(
128
+ webhook_id=parsed.webhook_id,
129
+ timestamp=parsed.timestamp,
130
+ body=body,
131
+ )
132
+ document = await self._resolver.resolve()
133
+ self._check_ownership(document)
134
+ if self._signatures_match(document, message, parsed.signatures):
135
+ return self._result(parsed.webhook_id, parsed.timestamp, body, document)
136
+
137
+ with observe_security(
138
+ self._observer,
139
+ SecurityOperation.KEY_REFRESH,
140
+ profile="standard_webhooks",
141
+ ) as observation:
142
+ refreshed = await self._resolver.refresh()
143
+ candidate = await self._resolver.resolve() if refreshed is None else refreshed
144
+ self._check_ownership(candidate)
145
+ matched = self._signatures_match(candidate, message, parsed.signatures)
146
+ if matched:
147
+ observation.set_outcome(SecurityOutcome.HIT if refreshed is None else SecurityOutcome.SUCCESS)
148
+ else:
149
+ observation.set_outcome(SecurityOutcome.MISS)
150
+ if matched:
151
+ return self._result(parsed.webhook_id, parsed.timestamp, body, candidate)
152
+ raise WebhookVerificationError(WebhookFailureCode.SIGNATURE_INVALID)
153
+
154
+ async def _claim_replay(
155
+ self,
156
+ result: VerifiedWebhook,
157
+ *,
158
+ links: Sequence[TraceCorrelation],
159
+ ) -> bool:
160
+ namespace = dumps(
161
+ (result.environment, result.owner, result.endpoint, result.webhook_id),
162
+ ensure_ascii=False,
163
+ separators=(",", ":"),
164
+ ).encode()
165
+ key = f"webhook:v1:{sha256(namespace).hexdigest()}"
166
+ with observe_security(
167
+ self._observer,
168
+ SecurityOperation.REPLAY_CHECK,
169
+ profile="standard_webhooks",
170
+ links=links,
171
+ ) as observation:
172
+ outcome = await self._replay_store.check_and_store(
173
+ key,
174
+ ttl_seconds=self._replay_ttl_seconds,
175
+ )
176
+ if outcome is ReplayOutcome.STORED:
177
+ observation.set_outcome(SecurityOutcome.STORED)
178
+ elif outcome is ReplayOutcome.REPLAY:
179
+ observation.set_outcome(SecurityOutcome.REPLAY)
180
+ elif outcome is ReplayOutcome.CAPACITY_EXCEEDED:
181
+ observation.set_outcome(SecurityOutcome.CAPACITY_EXCEEDED)
182
+ else:
183
+ observation.set_outcome(SecurityOutcome.UNAVAILABLE)
184
+ if outcome is ReplayOutcome.STORED:
185
+ return False
186
+ if outcome is ReplayOutcome.REPLAY:
187
+ return True
188
+ raise WebhookVerificationError(WebhookFailureCode.STORE_UNAVAILABLE)
189
+
190
+ def _check_timestamp(self, timestamp: int) -> None:
191
+ now = self._time_source()
192
+ if abs(now - timestamp) > self._tolerance:
193
+ raise WebhookVerificationError(WebhookFailureCode.TIMESTAMP_OUT_OF_TOLERANCE)
194
+
195
+ def _check_ownership(self, document: PublicKeyDocument) -> None:
196
+ if document.environment != self._expected_environment:
197
+ raise WebhookVerificationError(WebhookFailureCode.ENVIRONMENT_MISMATCH)
198
+ if document.owner != self._expected_owner:
199
+ raise WebhookVerificationError(WebhookFailureCode.OWNER_MISMATCH)
200
+ if document.endpoint != self._expected_endpoint:
201
+ raise WebhookVerificationError(WebhookFailureCode.ENDPOINT_MISMATCH)
202
+ now = self._time_source()
203
+ if now < document.not_before:
204
+ raise WebhookVerificationError(WebhookFailureCode.KEY_UNAVAILABLE)
205
+ if document.retire_after is not None and now > document.retire_after:
206
+ raise WebhookVerificationError(WebhookFailureCode.KEY_UNAVAILABLE)
207
+
208
+ def _signatures_match(
209
+ self,
210
+ document: PublicKeyDocument,
211
+ message: bytes,
212
+ signatures: tuple[bytes, ...],
213
+ ) -> bool:
214
+ for key in document.keys:
215
+ public_key = Ed25519PublicKey.from_public_bytes(key.public_key)
216
+ for signature in signatures:
217
+ try:
218
+ public_key.verify(signature, message)
219
+ except InvalidSignature:
220
+ continue
221
+ else:
222
+ return True
223
+ return False
224
+
225
+ def _result(
226
+ self,
227
+ webhook_id: str,
228
+ timestamp: int,
229
+ body: bytes,
230
+ document: PublicKeyDocument,
231
+ ) -> VerifiedWebhook:
232
+ return VerifiedWebhook(
233
+ webhook_id=webhook_id,
234
+ timestamp=timestamp,
235
+ body=body,
236
+ environment=document.environment,
237
+ owner=document.owner,
238
+ endpoint=document.endpoint,
239
+ key_document_version=document.version,
240
+ )
@@ -0,0 +1,70 @@
1
+ [project]
2
+ name = "authweave-webhooks"
3
+ version = "7.1.2"
4
+ description = "Asymmetric Standard Webhooks toolkit for AuthWeave integrations"
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 = ["webhooks", "ed25519", "standard-webhooks", "authentication", "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
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ redis = ["redis>=8.1.0,<9.0"]
33
+ httpx = ["httpx>=0.28.1,<1.0"]
34
+ litestar = ["litestar>=2.24.0,<3.0"]
35
+
36
+ [project.urls]
37
+ homepage = "https://github.com/ZYLVEXT/litestar-auth"
38
+ documentation = "https://zylvext.github.io/litestar-auth/"
39
+ source = "https://github.com/ZYLVEXT/litestar-auth"
40
+ tracker = "https://github.com/ZYLVEXT/litestar-auth/issues"
41
+
42
+ [build-system]
43
+ requires = ["hatchling==1.31.0"]
44
+ build-backend = "hatchling.build"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["authweave_webhooks"]
48
+
49
+ [tool.hatch.build.targets.sdist]
50
+ only-include = ["authweave_webhooks"]
51
+
52
+ [tool.coverage.run]
53
+ source = ["authweave_webhooks"]
54
+ branch = true
55
+
56
+ [tool.coverage.report]
57
+ fail_under = 100
58
+ show_missing = true
59
+
60
+ [tool.ty.environment]
61
+ python-version = "3.12"
62
+
63
+ [tool.ty.src]
64
+ include = ["authweave_webhooks", "tests"]
65
+
66
+ [tool.deptry]
67
+ experimental_namespace_package = true
68
+
69
+ [tool.deptry.per_rule_ignores]
70
+ DEP002 = ["httpx", "litestar", "redis"]