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.
- seampact_signed_webhooks-1.0.0/.gitignore +6 -0
- seampact_signed_webhooks-1.0.0/LICENSE +21 -0
- seampact_signed_webhooks-1.0.0/PKG-INFO +13 -0
- seampact_signed_webhooks-1.0.0/README.md +1 -0
- seampact_signed_webhooks-1.0.0/pyproject.toml +25 -0
- seampact_signed_webhooks-1.0.0/src/seampact/signed_webhooks/__init__.py +35 -0
- seampact_signed_webhooks-1.0.0/src/seampact/signed_webhooks/clock.py +19 -0
- seampact_signed_webhooks-1.0.0/src/seampact/signed_webhooks/placeholders.py +9 -0
- seampact_signed_webhooks-1.0.0/src/seampact/signed_webhooks/py.typed +0 -0
- seampact_signed_webhooks-1.0.0/src/seampact/signed_webhooks/refusals.py +78 -0
- seampact_signed_webhooks-1.0.0/src/seampact/signed_webhooks/scheme.py +59 -0
- seampact_signed_webhooks-1.0.0/src/seampact/signed_webhooks/secret_lookup.py +10 -0
- seampact_signed_webhooks-1.0.0/src/seampact/signed_webhooks/signed_message.py +29 -0
- seampact_signed_webhooks-1.0.0/src/seampact/signed_webhooks/verification.py +32 -0
- seampact_signed_webhooks-1.0.0/src/seampact/signed_webhooks/verifier.py +183 -0
|
@@ -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}"
|
|
File without changes
|
|
@@ -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
|
+
]
|