tradingsignalbot 0.1.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.
- tradingsignalbot-0.1.0/.gitignore +7 -0
- tradingsignalbot-0.1.0/LICENSE +21 -0
- tradingsignalbot-0.1.0/PKG-INFO +87 -0
- tradingsignalbot-0.1.0/README.md +68 -0
- tradingsignalbot-0.1.0/pyproject.toml +30 -0
- tradingsignalbot-0.1.0/src/tradingsignalbot/__init__.py +30 -0
- tradingsignalbot-0.1.0/src/tradingsignalbot/py.typed +0 -0
- tradingsignalbot-0.1.0/src/tradingsignalbot/webhooks.py +145 -0
- tradingsignalbot-0.1.0/tests/test_webhooks.py +90 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TradingSignalBot
|
|
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,87 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tradingsignalbot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Verify TradingSignalBot webhook signatures (HMAC-SHA256, constant-time, timestamp window). Standard library only. The full API client comes when the sandbox opens.
|
|
5
|
+
Project-URL: Homepage, https://tradingsignalbot.com/api/?via=tsb-sdk
|
|
6
|
+
Project-URL: Source, https://github.com/TradingSignalBot/sdk/tree/main/python
|
|
7
|
+
Project-URL: Issues, https://github.com/TradingSignalBot/sdk/issues
|
|
8
|
+
Author: TradingSignalBot
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: forex signals api,hmac,signature,telegram signal bot,trading signals,tradingsignalbot,webhook
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
15
|
+
Classifier: Topic :: Security :: Cryptography
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# tradingsignalbot
|
|
21
|
+
|
|
22
|
+
The Python client for the [TradingSignalBot API](https://tradingsignalbot.com/api/?via=tsb-sdk).
|
|
23
|
+
|
|
24
|
+
**The API is a preview.** It is not live yet, so this package holds only the part you need first: **verifying the signature of a webhook delivery**, so you know an event came from TradingSignalBot and was not changed or replayed. The full client (render a chart from your own levels, register webhooks, read signals) comes here when the sandbox opens. [Get early access](https://tradingsignalbot.com/?for=developers&via=tsb-sdk#start) to get sandbox keys first.
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pip install tradingsignalbot
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Standard library only (`hmac.compare_digest`), Python 3.8+, typed.
|
|
31
|
+
|
|
32
|
+
## Verify a delivery
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from flask import Flask, request, abort
|
|
36
|
+
from tradingsignalbot import verify_webhook, WebhookVerificationError
|
|
37
|
+
|
|
38
|
+
app = Flask(__name__)
|
|
39
|
+
|
|
40
|
+
@app.post("/hooks/signals")
|
|
41
|
+
def signals():
|
|
42
|
+
try:
|
|
43
|
+
event = verify_webhook(request.get_data(), request.headers.get("TSB-Signature"), SECRET)
|
|
44
|
+
except WebhookVerificationError as e:
|
|
45
|
+
abort(400, e.code)
|
|
46
|
+
return "", 204
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Pass the **raw body**, exactly as it arrived. Parsed and re-serialised JSON will not match.
|
|
50
|
+
|
|
51
|
+
## The scheme
|
|
52
|
+
|
|
53
|
+
The scheme, as the [API page](https://tradingsignalbot.com/api/?via=tsb-sdk) states it:
|
|
54
|
+
|
|
55
|
+
| | |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| Header | `TSB-Signature: t=<timestamp>,v1=<signature>` (shown on the preview page) |
|
|
58
|
+
| Timestamp | Unix seconds (the preview's example uses the event's time in seconds) |
|
|
59
|
+
| Signed | `"<timestamp>.<raw body>"`, HMAC-SHA256 with your webhook secret |
|
|
60
|
+
| Signature | lowercase hex, 64 characters |
|
|
61
|
+
| Window | the timestamp within 300 seconds of your clock, against replays *(`tolerance` changes it, 0 turns it off)* |
|
|
62
|
+
| Several `v1=` | any one matching is enough, for secret rotation |
|
|
63
|
+
|
|
64
|
+
The repository's `test-vectors.json` holds the cases both packages are tested against.
|
|
65
|
+
|
|
66
|
+
Both packages compare in constant time and check every signature and every secret, so a mismatch takes as long as a match.
|
|
67
|
+
|
|
68
|
+
## API
|
|
69
|
+
|
|
70
|
+
| Function | Does |
|
|
71
|
+
| --- | --- |
|
|
72
|
+
| `verify_webhook(body, header, secret, tolerance=300, now=None)` | Verifies, returns the parsed event |
|
|
73
|
+
| `verify_signature(...)` | Verifies, returns the timestamp; raises `WebhookVerificationError` |
|
|
74
|
+
| `is_valid_signature(...)` | `True` / `False` |
|
|
75
|
+
| `sign_payload(body, secret, timestamp=None)` | The header TradingSignalBot would send: for your tests |
|
|
76
|
+
| `compute_signature(body, secret, t)` | The hex HMAC |
|
|
77
|
+
| `parse_signature_header(header)` | `(timestamp, signatures)` |
|
|
78
|
+
|
|
79
|
+
`secret` can be a list while you rotate it. `WebhookVerificationError.code` is one of `missing_header`, `bad_header`, `no_signature`, `timestamp`, `mismatch`, `bad_json`.
|
|
80
|
+
|
|
81
|
+
## Licence
|
|
82
|
+
|
|
83
|
+
MIT. Source and the JavaScript package: [github.com/TradingSignalBot/sdk](https://github.com/TradingSignalBot/sdk).
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
Part of the Forexia network: InterestRateDecision.com · Forexia Academy · [TradingSignalBot](https://tradingsignalbot.com/?via=tsb-sdk) · PropSim · OpenCharts
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# tradingsignalbot
|
|
2
|
+
|
|
3
|
+
The Python client for the [TradingSignalBot API](https://tradingsignalbot.com/api/?via=tsb-sdk).
|
|
4
|
+
|
|
5
|
+
**The API is a preview.** It is not live yet, so this package holds only the part you need first: **verifying the signature of a webhook delivery**, so you know an event came from TradingSignalBot and was not changed or replayed. The full client (render a chart from your own levels, register webhooks, read signals) comes here when the sandbox opens. [Get early access](https://tradingsignalbot.com/?for=developers&via=tsb-sdk#start) to get sandbox keys first.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install tradingsignalbot
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Standard library only (`hmac.compare_digest`), Python 3.8+, typed.
|
|
12
|
+
|
|
13
|
+
## Verify a delivery
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from flask import Flask, request, abort
|
|
17
|
+
from tradingsignalbot import verify_webhook, WebhookVerificationError
|
|
18
|
+
|
|
19
|
+
app = Flask(__name__)
|
|
20
|
+
|
|
21
|
+
@app.post("/hooks/signals")
|
|
22
|
+
def signals():
|
|
23
|
+
try:
|
|
24
|
+
event = verify_webhook(request.get_data(), request.headers.get("TSB-Signature"), SECRET)
|
|
25
|
+
except WebhookVerificationError as e:
|
|
26
|
+
abort(400, e.code)
|
|
27
|
+
return "", 204
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Pass the **raw body**, exactly as it arrived. Parsed and re-serialised JSON will not match.
|
|
31
|
+
|
|
32
|
+
## The scheme
|
|
33
|
+
|
|
34
|
+
The scheme, as the [API page](https://tradingsignalbot.com/api/?via=tsb-sdk) states it:
|
|
35
|
+
|
|
36
|
+
| | |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| Header | `TSB-Signature: t=<timestamp>,v1=<signature>` (shown on the preview page) |
|
|
39
|
+
| Timestamp | Unix seconds (the preview's example uses the event's time in seconds) |
|
|
40
|
+
| Signed | `"<timestamp>.<raw body>"`, HMAC-SHA256 with your webhook secret |
|
|
41
|
+
| Signature | lowercase hex, 64 characters |
|
|
42
|
+
| Window | the timestamp within 300 seconds of your clock, against replays *(`tolerance` changes it, 0 turns it off)* |
|
|
43
|
+
| Several `v1=` | any one matching is enough, for secret rotation |
|
|
44
|
+
|
|
45
|
+
The repository's `test-vectors.json` holds the cases both packages are tested against.
|
|
46
|
+
|
|
47
|
+
Both packages compare in constant time and check every signature and every secret, so a mismatch takes as long as a match.
|
|
48
|
+
|
|
49
|
+
## API
|
|
50
|
+
|
|
51
|
+
| Function | Does |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `verify_webhook(body, header, secret, tolerance=300, now=None)` | Verifies, returns the parsed event |
|
|
54
|
+
| `verify_signature(...)` | Verifies, returns the timestamp; raises `WebhookVerificationError` |
|
|
55
|
+
| `is_valid_signature(...)` | `True` / `False` |
|
|
56
|
+
| `sign_payload(body, secret, timestamp=None)` | The header TradingSignalBot would send: for your tests |
|
|
57
|
+
| `compute_signature(body, secret, t)` | The hex HMAC |
|
|
58
|
+
| `parse_signature_header(header)` | `(timestamp, signatures)` |
|
|
59
|
+
|
|
60
|
+
`secret` can be a list while you rotate it. `WebhookVerificationError.code` is one of `missing_header`, `bad_header`, `no_signature`, `timestamp`, `mismatch`, `bad_json`.
|
|
61
|
+
|
|
62
|
+
## Licence
|
|
63
|
+
|
|
64
|
+
MIT. Source and the JavaScript package: [github.com/TradingSignalBot/sdk](https://github.com/TradingSignalBot/sdk).
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
Part of the Forexia network: InterestRateDecision.com · Forexia Academy · [TradingSignalBot](https://tradingsignalbot.com/?via=tsb-sdk) · PropSim · OpenCharts
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tradingsignalbot"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Verify TradingSignalBot webhook signatures (HMAC-SHA256, constant-time, timestamp window). Standard library only. The full API client comes when the sandbox opens."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "TradingSignalBot" }]
|
|
14
|
+
keywords = ["tradingsignalbot", "webhook", "signature", "hmac", "forex signals api", "telegram signal bot", "trading signals"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Office/Business :: Financial :: Investment",
|
|
18
|
+
"Topic :: Security :: Cryptography",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://tradingsignalbot.com/api/?via=tsb-sdk"
|
|
26
|
+
Source = "https://github.com/TradingSignalBot/sdk/tree/main/python"
|
|
27
|
+
Issues = "https://github.com/TradingSignalBot/sdk/issues"
|
|
28
|
+
|
|
29
|
+
[tool.hatch.build.targets.wheel]
|
|
30
|
+
packages = ["src/tradingsignalbot"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""TradingSignalBot: verify webhook signatures. The full API client comes when the sandbox opens.
|
|
2
|
+
|
|
3
|
+
https://tradingsignalbot.com/api/?via=tsb-sdk
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .webhooks import (
|
|
7
|
+
DEFAULT_TOLERANCE,
|
|
8
|
+
SIGNATURE_HEADER,
|
|
9
|
+
WebhookVerificationError,
|
|
10
|
+
compute_signature,
|
|
11
|
+
is_valid_signature,
|
|
12
|
+
parse_signature_header,
|
|
13
|
+
sign_payload,
|
|
14
|
+
verify_signature,
|
|
15
|
+
verify_webhook,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"DEFAULT_TOLERANCE",
|
|
22
|
+
"SIGNATURE_HEADER",
|
|
23
|
+
"WebhookVerificationError",
|
|
24
|
+
"compute_signature",
|
|
25
|
+
"is_valid_signature",
|
|
26
|
+
"parse_signature_header",
|
|
27
|
+
"sign_payload",
|
|
28
|
+
"verify_signature",
|
|
29
|
+
"verify_webhook",
|
|
30
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Verify that a webhook delivery came from TradingSignalBot.
|
|
2
|
+
|
|
3
|
+
The signature scheme (see README, "The scheme")::
|
|
4
|
+
|
|
5
|
+
header TSB-Signature: t=<unix seconds>,v1=<hex>[,v1=<hex>...]
|
|
6
|
+
signed f"{t}.{raw_body}", HMAC-SHA256 with your webhook secret, lowercase hex
|
|
7
|
+
window t within 300 seconds of now (configurable)
|
|
8
|
+
|
|
9
|
+
Standard library only.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import hmac
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
from typing import Any, Iterable, List, Optional, Sequence, Tuple, Union
|
|
20
|
+
|
|
21
|
+
SIGNATURE_HEADER = "TSB-Signature"
|
|
22
|
+
DEFAULT_TOLERANCE = 300
|
|
23
|
+
|
|
24
|
+
Payload = Union[bytes, bytearray, memoryview, str]
|
|
25
|
+
Secret = Union[str, bytes, Sequence[Union[str, bytes]]]
|
|
26
|
+
|
|
27
|
+
_HEX64 = re.compile(r"^[0-9a-fA-F]{64}$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WebhookVerificationError(Exception):
|
|
31
|
+
"""Raised when a delivery cannot be trusted. ``code`` says why:
|
|
32
|
+
missing_header, bad_header, no_signature, timestamp, mismatch, bad_json."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, message: str, code: str) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.code = code
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _bytes(payload: Payload) -> bytes:
|
|
40
|
+
if isinstance(payload, str):
|
|
41
|
+
return payload.encode("utf-8")
|
|
42
|
+
if isinstance(payload, (bytes, bytearray, memoryview)):
|
|
43
|
+
return bytes(payload)
|
|
44
|
+
raise TypeError("tradingsignalbot: pass the raw request body (bytes or str), not parsed JSON")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _key(secret: Union[str, bytes]) -> bytes:
|
|
48
|
+
if not secret:
|
|
49
|
+
raise TypeError("tradingsignalbot: the webhook secret is required")
|
|
50
|
+
return secret.encode("utf-8") if isinstance(secret, str) else bytes(secret)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_signature_header(header: Optional[Union[str, Iterable[str]]]) -> Tuple[int, List[str]]:
|
|
54
|
+
"""``t=...,v1=...,v1=...`` to ``(timestamp, signatures)``. Unknown schemes are ignored."""
|
|
55
|
+
if header is None or header == "":
|
|
56
|
+
raise WebhookVerificationError(f"missing {SIGNATURE_HEADER} header", "missing_header")
|
|
57
|
+
if not isinstance(header, str):
|
|
58
|
+
header = ",".join(header)
|
|
59
|
+
timestamp: Optional[int] = None
|
|
60
|
+
signatures: List[str] = []
|
|
61
|
+
for part in header.split(","):
|
|
62
|
+
if "=" not in part:
|
|
63
|
+
continue
|
|
64
|
+
k, v = part.split("=", 1)
|
|
65
|
+
k, v = k.strip(), v.strip()
|
|
66
|
+
if k == "t":
|
|
67
|
+
timestamp = int(v) if v.isdigit() and v.isascii() else None
|
|
68
|
+
elif k == "v1" and _HEX64.match(v):
|
|
69
|
+
signatures.append(v.lower())
|
|
70
|
+
if timestamp is None:
|
|
71
|
+
raise WebhookVerificationError(f"{SIGNATURE_HEADER} has no valid t=", "bad_header")
|
|
72
|
+
if not signatures:
|
|
73
|
+
raise WebhookVerificationError(f"{SIGNATURE_HEADER} has no v1= signature", "no_signature")
|
|
74
|
+
return timestamp, signatures
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def compute_signature(payload: Payload, secret: Union[str, bytes], timestamp: int) -> str:
|
|
78
|
+
"""HMAC-SHA256 of ``f"{timestamp}.{payload}"``, lowercase hex."""
|
|
79
|
+
msg = f"{timestamp}.".encode("ascii") + _bytes(payload)
|
|
80
|
+
return hmac.new(_key(secret), msg, hashlib.sha256).hexdigest()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def verify_signature(
|
|
84
|
+
payload: Payload,
|
|
85
|
+
header: Optional[Union[str, Iterable[str]]],
|
|
86
|
+
secret: Secret,
|
|
87
|
+
tolerance: int = DEFAULT_TOLERANCE,
|
|
88
|
+
now: Optional[int] = None,
|
|
89
|
+
) -> int:
|
|
90
|
+
"""Raise WebhookVerificationError unless the header is a valid, fresh
|
|
91
|
+
signature of the raw body. Returns the delivery's timestamp.
|
|
92
|
+
|
|
93
|
+
``secret`` may be a list while you rotate it. ``tolerance`` is in seconds;
|
|
94
|
+
0 turns the timestamp check off."""
|
|
95
|
+
timestamp, signatures = parse_signature_header(header)
|
|
96
|
+
now = int(time.time()) if now is None else now
|
|
97
|
+
if tolerance > 0 and abs(now - timestamp) > tolerance:
|
|
98
|
+
raise WebhookVerificationError(
|
|
99
|
+
f"timestamp {timestamp} is outside the {tolerance} s window (now {now})", "timestamp"
|
|
100
|
+
)
|
|
101
|
+
secrets = [secret] if isinstance(secret, (str, bytes)) else list(secret)
|
|
102
|
+
ok = False
|
|
103
|
+
for sec in secrets:
|
|
104
|
+
expected = compute_signature(payload, sec, timestamp)
|
|
105
|
+
for sig in signatures: # check them all: no early exit
|
|
106
|
+
ok = hmac.compare_digest(expected, sig) or ok
|
|
107
|
+
if not ok:
|
|
108
|
+
raise WebhookVerificationError("signature does not match", "mismatch")
|
|
109
|
+
return timestamp
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_valid_signature(
|
|
113
|
+
payload: Payload,
|
|
114
|
+
header: Optional[Union[str, Iterable[str]]],
|
|
115
|
+
secret: Secret,
|
|
116
|
+
tolerance: int = DEFAULT_TOLERANCE,
|
|
117
|
+
now: Optional[int] = None,
|
|
118
|
+
) -> bool:
|
|
119
|
+
"""Like verify_signature, but True or False instead of raising."""
|
|
120
|
+
try:
|
|
121
|
+
verify_signature(payload, header, secret, tolerance=tolerance, now=now)
|
|
122
|
+
return True
|
|
123
|
+
except WebhookVerificationError:
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def verify_webhook(
|
|
128
|
+
payload: Payload,
|
|
129
|
+
header: Optional[Union[str, Iterable[str]]],
|
|
130
|
+
secret: Secret,
|
|
131
|
+
tolerance: int = DEFAULT_TOLERANCE,
|
|
132
|
+
now: Optional[int] = None,
|
|
133
|
+
) -> Any:
|
|
134
|
+
"""Verify the delivery and return the parsed event (``{"type", "created", "data"}``)."""
|
|
135
|
+
verify_signature(payload, header, secret, tolerance=tolerance, now=now)
|
|
136
|
+
try:
|
|
137
|
+
return json.loads(_bytes(payload).decode("utf-8"))
|
|
138
|
+
except (ValueError, UnicodeDecodeError):
|
|
139
|
+
raise WebhookVerificationError("the body is not JSON", "bad_json") from None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def sign_payload(payload: Payload, secret: Union[str, bytes], timestamp: Optional[int] = None) -> str:
|
|
143
|
+
"""The header TradingSignalBot would send for this body: for your tests and local replays."""
|
|
144
|
+
t = int(time.time()) if timestamp is None else timestamp
|
|
145
|
+
return f"t={t},v1={compute_signature(payload, secret, t)}"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import pathlib
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1] / "src"))
|
|
9
|
+
|
|
10
|
+
from tradingsignalbot import ( # noqa: E402
|
|
11
|
+
WebhookVerificationError,
|
|
12
|
+
compute_signature,
|
|
13
|
+
is_valid_signature,
|
|
14
|
+
parse_signature_header,
|
|
15
|
+
sign_payload,
|
|
16
|
+
verify_signature,
|
|
17
|
+
verify_webhook,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
VECTORS = json.loads((pathlib.Path(__file__).resolve().parents[2] / "test-vectors.json").read_text("utf-8"))
|
|
21
|
+
SECRET = "whsec_test"
|
|
22
|
+
BODY = json.dumps({"type": "signal.target_hit", "created": "2026-10-14T19:30:00Z", "data": {"target": 2}})
|
|
23
|
+
T = 1792001400
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def code(fn, *a, **k):
|
|
27
|
+
try:
|
|
28
|
+
fn(*a, **k)
|
|
29
|
+
except WebhookVerificationError as e:
|
|
30
|
+
return e.code
|
|
31
|
+
return "ok"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Vectors(unittest.TestCase):
|
|
35
|
+
def test_shared_vectors(self):
|
|
36
|
+
for v in VECTORS["cases"]:
|
|
37
|
+
with self.subTest(v["name"]):
|
|
38
|
+
self.assertEqual(compute_signature(v["body"], v["secret"], v["timestamp"]), v["signature"])
|
|
39
|
+
self.assertEqual(code(verify_signature, v["body"], v["header"], v["secret"], now=v["now"]), v["expect"])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Verify(unittest.TestCase):
|
|
43
|
+
def test_plain_hmac(self):
|
|
44
|
+
expected = hmac.new(SECRET.encode(), f"{T}.{BODY}".encode(), hashlib.sha256).hexdigest()
|
|
45
|
+
self.assertEqual(compute_signature(BODY, SECRET, T), expected)
|
|
46
|
+
self.assertEqual(sign_payload(BODY, SECRET, timestamp=T), f"t={T},v1={expected}")
|
|
47
|
+
|
|
48
|
+
def test_good_delivery(self):
|
|
49
|
+
header = sign_payload(BODY, SECRET, timestamp=T)
|
|
50
|
+
event = verify_webhook(BODY.encode(), header, SECRET, now=T + 10)
|
|
51
|
+
self.assertEqual(event["type"], "signal.target_hit")
|
|
52
|
+
self.assertTrue(is_valid_signature(bytearray(BODY.encode()), header, SECRET, now=T))
|
|
53
|
+
self.assertEqual(verify_signature(BODY, header, SECRET, now=T), T)
|
|
54
|
+
|
|
55
|
+
def test_refused(self):
|
|
56
|
+
header = sign_payload(BODY, SECRET, timestamp=T)
|
|
57
|
+
self.assertEqual(code(verify_signature, BODY + " ", header, SECRET, now=T), "mismatch")
|
|
58
|
+
self.assertEqual(code(verify_signature, BODY, header, "other", now=T), "mismatch")
|
|
59
|
+
self.assertEqual(code(verify_signature, BODY, header, SECRET, now=T + 301), "timestamp")
|
|
60
|
+
self.assertEqual(code(verify_signature, BODY, header, SECRET, now=T - 301), "timestamp")
|
|
61
|
+
self.assertEqual(code(verify_signature, BODY, header, SECRET, now=T + 10_000, tolerance=0), "ok")
|
|
62
|
+
self.assertEqual(code(verify_signature, BODY, header.replace(f"t={T}", f"t={T + 1}"), SECRET, now=T), "mismatch")
|
|
63
|
+
|
|
64
|
+
def test_bad_headers(self):
|
|
65
|
+
self.assertEqual(code(verify_signature, BODY, None, SECRET), "missing_header")
|
|
66
|
+
self.assertEqual(code(verify_signature, BODY, "", SECRET), "missing_header")
|
|
67
|
+
self.assertEqual(code(verify_signature, BODY, "v1=" + "a" * 64, SECRET), "bad_header")
|
|
68
|
+
self.assertEqual(code(verify_signature, BODY, f"t={T}", SECRET, now=T), "no_signature")
|
|
69
|
+
self.assertEqual(code(verify_signature, BODY, f"t={T},v1=short", SECRET, now=T), "no_signature")
|
|
70
|
+
self.assertEqual(code(verify_signature, BODY, "t=-5,v1=" + "a" * 64, SECRET), "bad_header")
|
|
71
|
+
|
|
72
|
+
def test_rotation(self):
|
|
73
|
+
sig = compute_signature(BODY, SECRET, T)
|
|
74
|
+
header = f"t={T},v1={'0' * 64},v1={sig},v0=whatever"
|
|
75
|
+
self.assertEqual(len(parse_signature_header(header)[1]), 2)
|
|
76
|
+
self.assertTrue(is_valid_signature(BODY, header, SECRET, now=T))
|
|
77
|
+
self.assertTrue(is_valid_signature(BODY, f"t={T},v1={sig}", ["new_secret", SECRET], now=T))
|
|
78
|
+
self.assertTrue(is_valid_signature(BODY, f" t={T} , v1={sig.upper()} ", SECRET, now=T))
|
|
79
|
+
|
|
80
|
+
def test_parsed_json_refused(self):
|
|
81
|
+
with self.assertRaises(TypeError):
|
|
82
|
+
verify_signature(json.loads(BODY), f"t={T},v1={'a' * 64}", SECRET, now=T)
|
|
83
|
+
|
|
84
|
+
def test_not_json(self):
|
|
85
|
+
header = sign_payload("not json", SECRET, timestamp=T)
|
|
86
|
+
self.assertEqual(code(verify_webhook, "not json", header, SECRET, now=T), "bad_json")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
unittest.main()
|