seampact-signed-webhooks 1.0.0__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,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ .mypy_cache/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Julien Metral
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,13 @@
1
+ Metadata-Version: 2.5
2
+ Name: seampact-signed-webhooks
3
+ Version: 1.0.0
4
+ Summary: Verifies a template-signed webhook (HMAC, timestamp tolerance, secret rotation) inside an authenticate hook.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Classifier: Typing :: Typed
10
+ Requires-Python: >=3.12
11
+ Description-Content-Type: text/markdown
12
+
13
+ The template signature verifier an authenticate hook calls for a signed identity (ADR 0013), for Python apps.
@@ -0,0 +1 @@
1
+ The template signature verifier an authenticate hook calls for a signed identity (ADR 0013), for Python apps.
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.33.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "seampact-signed-webhooks"
7
+ version = "1.0.0"
8
+ description = "Verifies a template-signed webhook (HMAC, timestamp tolerance, secret rotation) inside an authenticate hook."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.12"
13
+ dependencies = []
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Typing :: Typed",
18
+ ]
19
+
20
+ # PEP 420: `seampact` is a namespace shared by every seampact distribution, so it has no `__init__.py`.
21
+ [tool.hatch.build.targets.wheel]
22
+ packages = ["src/seampact"]
23
+
24
+ [tool.hatch.build.targets.sdist]
25
+ include = ["src/seampact", "LICENSE"]
@@ -0,0 +1,35 @@
1
+ """The template signature verifier an authenticate hook calls for a signed identity (ADR 0013).
2
+
3
+ HMAC over the raw body, timestamp tolerance and secret rotation; the application keeps its secrets.
4
+ """
5
+
6
+ from seampact.signed_webhooks.clock import Clock, SystemClock
7
+ from seampact.signed_webhooks.placeholders import SUPPORTED_ALGORITHM
8
+ from seampact.signed_webhooks.refusals import (
9
+ EmptySignatureSecretError,
10
+ SignaturePayloadInvalidError,
11
+ SignatureToleranceInvalidError,
12
+ UnsupportedSignatureAlgorithmError,
13
+ )
14
+ from seampact.signed_webhooks.scheme import SignatureEncoding, SignatureScheme
15
+ from seampact.signed_webhooks.secret_lookup import SecretLookup
16
+ from seampact.signed_webhooks.signed_message import SignedMessage
17
+ from seampact.signed_webhooks.verification import SignatureFailure, SignatureVerification
18
+ from seampact.signed_webhooks.verifier import TemplateSignatureVerifier
19
+
20
+ __all__ = [
21
+ "SUPPORTED_ALGORITHM",
22
+ "Clock",
23
+ "EmptySignatureSecretError",
24
+ "SecretLookup",
25
+ "SignatureEncoding",
26
+ "SignatureFailure",
27
+ "SignaturePayloadInvalidError",
28
+ "SignatureScheme",
29
+ "SignatureToleranceInvalidError",
30
+ "SignatureVerification",
31
+ "SignedMessage",
32
+ "SystemClock",
33
+ "TemplateSignatureVerifier",
34
+ "UnsupportedSignatureAlgorithmError",
35
+ ]
@@ -0,0 +1,19 @@
1
+ """The clock the timestamp tolerance is checked against: the system clock, or a fixed one in a test."""
2
+
3
+ import time
4
+ from typing import Final, Protocol
5
+
6
+ _NANOSECONDS_PER_MILLISECOND: Final = 1_000_000
7
+
8
+
9
+ class Clock(Protocol):
10
+ def now_milliseconds(self) -> int:
11
+ """Milliseconds since the Unix epoch."""
12
+ ...
13
+
14
+
15
+ class SystemClock:
16
+ """The system clock."""
17
+
18
+ def now_milliseconds(self) -> int:
19
+ return time.time_ns() // _NANOSECONDS_PER_MILLISECOND
@@ -0,0 +1,9 @@
1
+ """The constants of a template signature scheme: the placeholders of its payload and the one algorithm computed."""
2
+
3
+ from typing import Final
4
+
5
+ # The only MAC this verifier computes; the IR enum is open, so another algorithm is refused by name.
6
+ SUPPORTED_ALGORITHM: Final = "hmac-sha256"
7
+
8
+ TIMESTAMP_PLACEHOLDER: Final = "{ts}"
9
+ RAW_BODY_PLACEHOLDER: Final = "{rawBody}"
@@ -0,0 +1,78 @@
1
+ """The refusals of the verifier: a scheme or a secret it cannot check safely, each with a stable code."""
2
+
3
+ from typing import ClassVar, Self
4
+
5
+ from seampact.signed_webhooks.placeholders import SUPPORTED_ALGORITHM
6
+
7
+
8
+ class UnsupportedSignatureAlgorithmError(ValueError):
9
+ """The scheme names an algorithm this verifier does not compute: a contract it cannot verify."""
10
+
11
+ CODE: ClassVar[str] = "signature.algorithm.unsupported"
12
+
13
+ def __init__(self, algorithm: str) -> None:
14
+ super().__init__(
15
+ f"The signature algorithm '{algorithm}' is not one seampact-signed-webhooks computes; it computes "
16
+ f"'{SUPPORTED_ALGORITHM}'. Verify this scheme with 'verify: application', or update the package to a "
17
+ "release that computes it."
18
+ )
19
+ self.algorithm = algorithm
20
+
21
+
22
+ class SignaturePayloadInvalidError(ValueError):
23
+ """The signed payload does not hold `{rawBody}` exactly once and `{ts}` at most once."""
24
+
25
+ CODE: ClassVar[str] = "signature.payload.invalid"
26
+
27
+ def __init__(self, payload: str) -> None:
28
+ super().__init__(
29
+ f"The signed payload '{payload}' must hold {{rawBody}} exactly once and {{ts}} at most once, or a "
30
+ "captured signature could pass with another body. Copy the payload of the hook's signature template."
31
+ )
32
+ self.payload = payload
33
+
34
+
35
+ class SignatureToleranceInvalidError(ValueError):
36
+ """The tolerance does not fit the payload: missing for a `{ts}` payload, negative, or given without `{ts}`."""
37
+
38
+ CODE: ClassVar[str] = "signature.tolerance.invalid"
39
+
40
+ @classmethod
41
+ def missing(cls, payload: str) -> Self:
42
+ return cls(
43
+ f"The signed payload '{payload}' holds {{ts}} but the scheme has no tolerance, so a replayed request of "
44
+ "any age would pass. Pass the tolerance of the hook's signature template as tolerance_ms."
45
+ )
46
+
47
+ @classmethod
48
+ def not_whole_milliseconds(cls, tolerance_ms: object) -> Self:
49
+ return cls(
50
+ f"The tolerance {tolerance_ms!r} ms is not a whole number of milliseconds of at least 0, so the "
51
+ "timestamp check would not hold. Pass the tolerance of the hook's signature template as tolerance_ms."
52
+ )
53
+
54
+ @classmethod
55
+ def without_timestamp(cls, payload: str) -> Self:
56
+ return cls(
57
+ f"The scheme has a tolerance but its signed payload '{payload}' holds no {{ts}}, so there is no "
58
+ "timestamp to check. Drop the tolerance, as the hook's signature template does."
59
+ )
60
+
61
+
62
+ class EmptySignatureSecretError(ValueError):
63
+ """The lookup returned an empty secret, one that is neither text nor bytes, or text that does not encode as UTF-8:
64
+ no MAC can be computed safely with it.
65
+
66
+ The message names the key id and the position in the list, never a secret.
67
+ """
68
+
69
+ CODE: ClassVar[str] = "signature.secret.empty"
70
+
71
+ def __init__(self, key_id: str, index: int) -> None:
72
+ super().__init__(
73
+ f"The secret at position {index} looked up for the key '{key_id}' is empty, neither text nor bytes, or "
74
+ "text that does not encode as UTF-8, so no request can be verified safely with it. Configure the secret "
75
+ "(check the environment variable behind the lookup)."
76
+ )
77
+ self.key_id = key_id
78
+ self.index = index
@@ -0,0 +1,59 @@
1
+ """A template signature scheme (ADR 0013): the MAC algorithm, the signed payload, the encoding and the tolerance."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+
6
+ from seampact.signed_webhooks.placeholders import RAW_BODY_PLACEHOLDER, TIMESTAMP_PLACEHOLDER
7
+ from seampact.signed_webhooks.refusals import SignaturePayloadInvalidError, SignatureToleranceInvalidError
8
+
9
+
10
+ class SignatureEncoding(StrEnum):
11
+ """How a signature scheme writes its MAC in the header."""
12
+
13
+ HEX = "hex"
14
+ BASE64 = "base64"
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class SignatureScheme:
19
+ """The scheme the application copies from the hook's signature template, tolerance included.
20
+
21
+ Raises `SignaturePayloadInvalidError` when the payload does not hold `{rawBody}` exactly once and `{ts}` at most
22
+ once, and `SignatureToleranceInvalidError` when the tolerance is missing for a `{ts}` payload, is not a whole
23
+ number of milliseconds of at least 0, or is given for a payload with no `{ts}`.
24
+ """
25
+
26
+ algorithm: str
27
+ payload: str
28
+ encoding: SignatureEncoding
29
+ tolerance_ms: int | None = None
30
+
31
+ def __post_init__(self) -> None:
32
+ # A plain "hex" or "base64" becomes the enum member; any other text raises the enum's ValueError.
33
+ object.__setattr__(self, "encoding", SignatureEncoding(self.encoding))
34
+
35
+ timestamp_count = self.payload.count(TIMESTAMP_PLACEHOLDER)
36
+
37
+ if self.payload.count(RAW_BODY_PLACEHOLDER) != 1 or timestamp_count > 1:
38
+ raise SignaturePayloadInvalidError(self.payload)
39
+
40
+ _refuse_unfit_tolerance(self.payload, self.tolerance_ms, timestamp_count)
41
+
42
+ @property
43
+ def signs_timestamp(self) -> bool:
44
+ return TIMESTAMP_PLACEHOLDER in self.payload
45
+
46
+
47
+ def _refuse_unfit_tolerance(payload: str, tolerance_ms: int | None, timestamp_count: int) -> None:
48
+ if tolerance_ms is None:
49
+ if timestamp_count == 1:
50
+ raise SignatureToleranceInvalidError.missing(payload)
51
+
52
+ return
53
+
54
+ # A bool is an int in Python, and a float could be NaN: neither is a tolerance.
55
+ if isinstance(tolerance_ms, bool) or not isinstance(tolerance_ms, int) or tolerance_ms < 0:
56
+ raise SignatureToleranceInvalidError.not_whole_milliseconds(tolerance_ms)
57
+
58
+ if timestamp_count == 0:
59
+ raise SignatureToleranceInvalidError.without_timestamp(payload)
@@ -0,0 +1,10 @@
1
+ """The secrets currently valid for a key id: one, or several during a rotation. The verifier never keeps them."""
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Protocol
5
+
6
+
7
+ class SecretLookup(Protocol):
8
+ def secrets_for(self, key_id: str) -> Sequence[str | bytes]:
9
+ """The secrets of the key id, text as UTF-8 or bytes; none when the key id is unknown."""
10
+ ...
@@ -0,0 +1,29 @@
1
+ """What a template-signed request carries: the raw body and the signature header's timestamp and candidates.
2
+
3
+ Its `repr` never shows the body or a signature, so a log line, a debugger or an error tracker that captures frame
4
+ locals sees no sensitive byte.
5
+ """
6
+
7
+ from collections.abc import Iterator
8
+ from dataclasses import dataclass
9
+ from typing import Final
10
+
11
+ REDACTED: Final = "[redacted]"
12
+
13
+
14
+ @dataclass(frozen=True, slots=True, repr=False)
15
+ class SignedMessage:
16
+ """The decoded body bytes exactly as received, the signed timestamp text as received when the header carries one,
17
+ and every signature the header carries, still encoded."""
18
+
19
+ raw_body: bytes
20
+ timestamp: str | None
21
+ candidate_signatures: tuple[str, ...]
22
+
23
+ def __repr__(self) -> str:
24
+ return f"SignedMessage(raw_body={REDACTED}, timestamp={self.timestamp!r}, candidate_signatures={REDACTED})"
25
+
26
+ def __rich_repr__(self) -> Iterator[tuple[str, object]]:
27
+ yield "raw_body", REDACTED
28
+ yield "timestamp", self.timestamp
29
+ yield "candidate_signatures", REDACTED
@@ -0,0 +1,32 @@
1
+ """The verifier's answer: verified, or the failure that refused the request."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import StrEnum
5
+ from typing import Self
6
+
7
+
8
+ class SignatureFailure(StrEnum):
9
+ """Why a signature failed; the authenticate hook answers its declared 401 for each of them."""
10
+
11
+ NO_SECRET = "no-secret"
12
+ TIMESTAMP_MISSING = "timestamp-missing"
13
+ TIMESTAMP_MALFORMED = "timestamp-malformed"
14
+ TIMESTAMP_OUTSIDE_TOLERANCE = "timestamp-outside-tolerance"
15
+ SIGNATURE_MISMATCH = "signature-mismatch"
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class SignatureVerification:
20
+ failure: SignatureFailure | None
21
+
22
+ @classmethod
23
+ def passed(cls) -> Self:
24
+ return cls(None)
25
+
26
+ @classmethod
27
+ def failed(cls, failure: SignatureFailure) -> Self:
28
+ return cls(failure)
29
+
30
+ @property
31
+ def verified(self) -> bool:
32
+ return self.failure is None
@@ -0,0 +1,183 @@
1
+ """Verifies a template-signed request (ADR 0013 "Where the verification happens").
2
+
3
+ Rebuilds the signed payload from the raw bytes, computes the MAC with each valid secret, compares it in constant time
4
+ with each candidate, then checks the timestamp against the tolerance in both directions.
5
+ """
6
+
7
+ import base64
8
+ import contextlib
9
+ import hashlib
10
+ import hmac
11
+ import re
12
+ from collections.abc import Sequence
13
+ from typing import Final
14
+
15
+ from seampact.signed_webhooks.clock import Clock, SystemClock
16
+ from seampact.signed_webhooks.placeholders import RAW_BODY_PLACEHOLDER, SUPPORTED_ALGORITHM, TIMESTAMP_PLACEHOLDER
17
+ from seampact.signed_webhooks.refusals import EmptySignatureSecretError, UnsupportedSignatureAlgorithmError
18
+ from seampact.signed_webhooks.scheme import SignatureEncoding, SignatureScheme
19
+ from seampact.signed_webhooks.secret_lookup import SecretLookup
20
+ from seampact.signed_webhooks.signed_message import REDACTED, SignedMessage
21
+ from seampact.signed_webhooks.verification import SignatureFailure, SignatureVerification
22
+
23
+ _HMAC_SHA256_BYTES: Final = 32
24
+ _MILLISECONDS_PER_SECOND: Final = 1000
25
+ # Longer than any Unix time in seconds for millions of years; beyond it the timestamp is outside every tolerance,
26
+ # and `int()` of a very long text would raise.
27
+ _MAX_TIMESTAMP_DIGITS: Final = 20
28
+ _UNIX_SECONDS: Final = re.compile(r"[0-9]+")
29
+ _HEX: Final = re.compile(r"(?:[0-9a-fA-F]{2})+")
30
+ _BASE64: Final = re.compile(r"(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?")
31
+
32
+
33
+ class _LookedUpSecrets:
34
+ """The secrets of one verification, held so that a frame capturing them shows no secret."""
35
+
36
+ __slots__ = ("_secrets",)
37
+
38
+ def __init__(self, secrets: Sequence[str | bytes]) -> None:
39
+ self._secrets = tuple(secrets)
40
+
41
+ def __repr__(self) -> str:
42
+ return REDACTED
43
+
44
+ def is_empty(self) -> bool:
45
+ return len(self._secrets) == 0
46
+
47
+ # Every secret is checked before any is kept or hashed, and no secret is ever bound to a local name: a frame
48
+ # captured at a refusal holds only this redacted holder and a position.
49
+ def usable(self, key_id: str) -> "_UsableSecrets":
50
+ for index in range(len(self._secrets)):
51
+ if not _is_usable_secret(self._secrets[index]):
52
+ raise EmptySignatureSecretError(key_id, index)
53
+
54
+ return _UsableSecrets(tuple(self._encoded(index, key_id) for index in range(len(self._secrets))))
55
+
56
+ # A text secret that is not valid Unicode (a byte that is not UTF-8 in an environment variable) is refused. The
57
+ # refusal is raised outside the handler, so it carries neither the encoding error nor the secret inside it.
58
+ def _encoded(self, index: int, key_id: str) -> bytes:
59
+ with contextlib.suppress(UnicodeEncodeError):
60
+ return _as_utf8(self._secrets[index])
61
+
62
+ raise EmptySignatureSecretError(key_id, index) from None
63
+
64
+
65
+ class _UsableSecrets:
66
+ """The encoded secrets of one verification, kept in the holder through the MAC loop."""
67
+
68
+ __slots__ = ("_secrets",)
69
+
70
+ def __init__(self, secrets: tuple[bytes, ...]) -> None:
71
+ self._secrets = secrets
72
+
73
+ def __repr__(self) -> str:
74
+ return REDACTED
75
+
76
+ # Every secret against every candidate, without stopping at the first match.
77
+ def any_matches(self, payload: bytes, candidates: Sequence[bytes]) -> bool:
78
+ matched = False
79
+
80
+ for index in range(len(self._secrets)):
81
+ expected = hmac.new(self._secrets[index], payload, hashlib.sha256).digest()
82
+
83
+ for candidate in candidates:
84
+ if hmac.compare_digest(expected, candidate):
85
+ matched = True
86
+
87
+ return matched
88
+
89
+
90
+ def _as_utf8(secret: str | bytes) -> bytes:
91
+ return secret.encode("utf-8") if isinstance(secret, str) else secret
92
+
93
+
94
+ def _is_usable_secret(secret: object) -> bool:
95
+ return isinstance(secret, str | bytes) and len(secret) > 0
96
+
97
+
98
+ class TemplateSignatureVerifier:
99
+ """Verifies template-signed requests with the application's secrets; the clock is injectable for tests."""
100
+
101
+ __slots__ = ("_clock", "_secret_lookup")
102
+
103
+ def __init__(self, secret_lookup: SecretLookup, clock: Clock | None = None) -> None:
104
+ self._secret_lookup = secret_lookup
105
+ self._clock: Clock = SystemClock() if clock is None else clock
106
+
107
+ def verify(self, message: SignedMessage, key_id: str, scheme: SignatureScheme) -> SignatureVerification:
108
+ """Raises `UnsupportedSignatureAlgorithmError` for another algorithm than hmac-sha256, and
109
+ `EmptySignatureSecretError` when the lookup returns an empty secret, one that is neither text nor bytes, or
110
+ text that does not encode as UTF-8."""
111
+ if scheme.algorithm != SUPPORTED_ALGORITHM:
112
+ raise UnsupportedSignatureAlgorithmError(scheme.algorithm)
113
+
114
+ if scheme.signs_timestamp and message.timestamp is None:
115
+ return SignatureVerification.failed(SignatureFailure.TIMESTAMP_MISSING)
116
+
117
+ secrets = _LookedUpSecrets(self._secret_lookup.secrets_for(key_id))
118
+
119
+ if secrets.is_empty():
120
+ return SignatureVerification.failed(SignatureFailure.NO_SECRET)
121
+
122
+ usable_secrets = secrets.usable(key_id)
123
+
124
+ if not usable_secrets.any_matches(_signed_payload(message, scheme), _decoded_candidates(message, scheme)):
125
+ return SignatureVerification.failed(SignatureFailure.SIGNATURE_MISMATCH)
126
+
127
+ timestamp_failure = self._timestamp_failure(message.timestamp, scheme.tolerance_ms)
128
+
129
+ if timestamp_failure is not None:
130
+ return SignatureVerification.failed(timestamp_failure)
131
+
132
+ return SignatureVerification.passed()
133
+
134
+ def _timestamp_failure(self, timestamp: str | None, tolerance_ms: int | None) -> SignatureFailure | None:
135
+ if tolerance_ms is None:
136
+ return None
137
+
138
+ if timestamp is None:
139
+ return SignatureFailure.TIMESTAMP_MISSING
140
+
141
+ if _UNIX_SECONDS.fullmatch(timestamp) is None:
142
+ return SignatureFailure.TIMESTAMP_MALFORMED
143
+
144
+ if len(timestamp) > _MAX_TIMESTAMP_DIGITS:
145
+ return SignatureFailure.TIMESTAMP_OUTSIDE_TOLERANCE
146
+
147
+ signed_at_ms = int(timestamp) * _MILLISECONDS_PER_SECOND
148
+
149
+ if abs(self._clock.now_milliseconds() - signed_at_ms) > tolerance_ms:
150
+ return SignatureFailure.TIMESTAMP_OUTSIDE_TOLERANCE
151
+
152
+ return None
153
+
154
+
155
+ # The payload with `{ts}` replaced by the timestamp text as received and `{rawBody}` by the raw bytes.
156
+ def _signed_payload(message: SignedMessage, scheme: SignatureScheme) -> bytes:
157
+ before_body, after_body = scheme.payload.split(RAW_BODY_PLACEHOLDER, 1)
158
+ timestamp = message.timestamp or ""
159
+
160
+ return (
161
+ before_body.replace(TIMESTAMP_PLACEHOLDER, timestamp).encode("utf-8")
162
+ + message.raw_body
163
+ + after_body.replace(TIMESTAMP_PLACEHOLDER, timestamp).encode("utf-8")
164
+ )
165
+
166
+
167
+ # A candidate that does not decode in the declared encoding, or decodes to the wrong length, never matches. The
168
+ # patterns admit only text the decoders accept.
169
+ def _decoded_candidate(candidate: str, encoding: SignatureEncoding) -> bytes | None:
170
+ if encoding is SignatureEncoding.HEX:
171
+ decoded = bytes.fromhex(candidate) if _HEX.fullmatch(candidate) else None
172
+ else:
173
+ decoded = base64.b64decode(candidate, validate=True) if _BASE64.fullmatch(candidate) else None
174
+
175
+ return decoded if decoded is not None and len(decoded) == _HMAC_SHA256_BYTES else None
176
+
177
+
178
+ def _decoded_candidates(message: SignedMessage, scheme: SignatureScheme) -> list[bytes]:
179
+ return [
180
+ decoded
181
+ for decoded in (_decoded_candidate(candidate, scheme.encoding) for candidate in message.candidate_signatures)
182
+ if decoded is not None
183
+ ]