Skip to content

Commit 7cc13e6

Browse files
authored
Merge pull request #207 from DomainTools/IDEV-2586
[IDEV-2586]: feat: add HMAC-SHA256 signing support for RTTF feed endpoints
2 parents b024d06 + b5e448c commit 7cc13e6

4 files changed

Lines changed: 92 additions & 15 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,12 @@ Custom parameters aside from the common `GET` Request parameters:
269269
api = API(USERNAME, KEY, header_authentication=False)
270270
api.nod(**kwargs)
271271
```
272+
- `always_sign_api_key`: set to `True` to use HMAC-SHA256 signed authentication instead of header auth. When set, `header_authentication` automatically defaults to `False` — both methods do not fire simultaneously. The signing algorithm is identical to the standard API: `HMAC-SHA256(key, username + timestamp + path)`, with `timestamp` and `signature` sent as query parameters.
273+
```python
274+
api = API(USERNAME, KEY, always_sign_api_key=True)
275+
api.nod(after="-60")
276+
# sends: api_username, timestamp, signature — no X-Api-Key header
277+
```
272278
- `output_format`: (choose either `csv` or `jsonl` - default is `jsonl`). Cannot be used in `domainrdap` feeds. Additionally, `csv` is not available for `download` endpoints.
273279
```python
274280
api = API(USERNAME, KEY)

domaintools/api.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -196,17 +196,16 @@ def _handle_api_key_parameters(self, is_rttf_product):
196196
self.always_sign_api_key = not is_rttf_product
197197

198198
if self.header_authentication is None:
199-
self.header_authentication = is_rttf_product
199+
# When HMAC signing is explicitly requested for RTTF, disable header auth
200+
# so both methods don't fire simultaneously
201+
self.header_authentication = is_rttf_product and not self.always_sign_api_key
200202

201203
def handle_api_key(self, is_rttf_product, path, parameters):
202204
if self.header_authentication and not self.always_sign_api_key:
203205
return
204206
if self.https and not self.always_sign_api_key:
205207
parameters["api_key"] = self.key
206208
else:
207-
if is_rttf_product:
208-
# As per requirement in IDEV-2272, raise this error when the user explicitly sets signing of API key for RTTF endpoints
209-
raise ValueError("Real Time Threat Feeds do not support signed API keys.")
210209
if self.key_sign_hash and self.key_sign_hash in AVAILABLE_KEY_SIGN_HASHES:
211210
signing_hash = eval(self.key_sign_hash)
212211
else:
@@ -215,10 +214,12 @@ def handle_api_key(self, is_rttf_product, path, parameters):
215214
"Values available are {1}".format(self.key_sign_hash, ",".join(AVAILABLE_KEY_SIGN_HASHES))
216215
)
217216

217+
# RTTF paths lack a leading slash; normalize before signing
218+
sign_path = path if path.startswith("/") else f"/{path}"
218219
parameters["timestamp"] = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
219220
parameters["signature"] = hmac(
220221
self.key.encode("utf8"),
221-
"".join([self.username, parameters["timestamp"], path]).encode("utf8"),
222+
"".join([self.username, parameters["timestamp"], sign_path]).encode("utf8"),
222223
digestmod=signing_hash,
223224
).hexdigest()
224225

examples/rttf_feeds.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from domaintools import API
2+
3+
api = API(USER_NAME, KEY)
4+
5+
# --- Stream feed (default) ---
6+
# Streams newly observed domains from the last 60 seconds.
7+
results = api.nod(after="-60", top=10)
8+
for line in results.response():
9+
print(line)
10+
11+
# --- Stream feed with HMAC signing ---
12+
# Uses HMAC-SHA256 instead of the default X-Api-Key header.
13+
# header_authentication is automatically disabled when always_sign_api_key=True.
14+
hmac_api = API(USER_NAME, KEY, always_sign_api_key=True)
15+
results = hmac_api.nod(after="-60", top=10)
16+
for line in results.response():
17+
print(line)
18+
19+
# --- Stream feed with sessionID ---
20+
# Each subsequent call returns only data since the last request.
21+
results = api.nod(sessionID="my-session", after="-3600", top=10)
22+
for line in results.response():
23+
print(line)
24+
25+
# --- Download endpoint ---
26+
# Returns a JSON listing of available S3 batch files (not a stream).
27+
result = api.nod(endpoint="download", limit=5)
28+
print(result["download_name"])
29+
for f in result["files"]:
30+
print(f["name"], f["url"])

tests/test_api.py

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -895,16 +895,56 @@ def test_ip_risk():
895895
assert "all_threats_combined_percent" in feed_result.keys()
896896

897897

898-
@vcr.use_cassette
899-
def test_feeds_endpoint_should_raise_error_if_signed_api_key_is_used():
900-
feeds_api.always_sign_api_key = True
901-
try:
902-
with pytest.raises(ValueError) as excinfo:
903-
feeds_api.domaindiscovery(after="-60")
904-
assert str(excinfo.value) == "Real Time Threat Feeds do not support signed API keys."
905-
finally:
906-
feeds_api.always_sign_api_key = False
907-
feeds_api.header_authentication = True
898+
def test_rttf_hmac_produces_timestamp_and_signature_not_api_key():
899+
"""RTTF with always_sign_api_key=True must add timestamp+signature and omit api_key."""
900+
hmac_api = API("testuser", "testkey", rate_limit=False, always_sign_api_key=True)
901+
result = hmac_api.nod(after="-60")
902+
session_info = result._get_session_params_and_headers()
903+
params = session_info["parameters"]
904+
905+
assert "timestamp" in params
906+
assert "signature" in params
907+
assert "api_key" not in params
908+
assert "X-Api-Key" not in session_info["headers"]
909+
910+
911+
def test_rttf_hmac_auto_disables_header_authentication():
912+
"""When always_sign_api_key=True, header_authentication must default to False for RTTF."""
913+
hmac_api = API("testuser", "testkey", rate_limit=False, always_sign_api_key=True)
914+
hmac_api.nod(after="-60")
915+
assert hmac_api.header_authentication is False
916+
917+
918+
def test_rttf_hmac_signature_is_correct():
919+
"""RTTF HMAC signature must match manual calculation using the normalised /v1/feed/... path."""
920+
from hashlib import sha256
921+
from hmac import new as hmac_new
922+
923+
hmac_api = API("testuser", "testkey", rate_limit=False, always_sign_api_key=True)
924+
result = hmac_api.nod(after="-60")
925+
params = result._get_session_params_and_headers()["parameters"]
926+
927+
ts = params["timestamp"]
928+
expected = hmac_new(
929+
"testkey".encode("utf8"),
930+
f"testuser{ts}/v1/feed/nod/".encode("utf8"),
931+
digestmod=sha256,
932+
).hexdigest()
933+
assert params["signature"] == expected
934+
935+
936+
def test_rttf_hmac_explicit_header_auth_false_still_signs():
937+
"""Explicit header_authentication=False with always_sign_api_key=True must produce a signature."""
938+
hmac_api = API(
939+
"testuser", "testkey",
940+
rate_limit=False,
941+
always_sign_api_key=True,
942+
header_authentication=False,
943+
)
944+
result = hmac_api.nod(after="-60")
945+
params = result._get_session_params_and_headers()["parameters"]
946+
assert "signature" in params
947+
assert "api_key" not in params
908948

909949

910950
def test_rttf_api_key_not_leaked_as_query_param():

0 commit comments

Comments
 (0)