mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-05-08 02:16:41 +02:00
250 lines
9.3 KiB
Python
250 lines
9.3 KiB
Python
"""Tests for OpenClaw direct-mode HMAC body binding (Packet P1A).
|
|
|
|
Proves that:
|
|
1. Tampered request bodies are rejected.
|
|
2. Untampered request bodies are accepted.
|
|
3. Nonce replay protection still works.
|
|
4. Timestamp freshness still works.
|
|
5. Bodyless (GET) requests still work.
|
|
"""
|
|
|
|
import hashlib
|
|
import hmac as hmac_mod
|
|
import secrets
|
|
import time
|
|
|
|
import pytest
|
|
from starlette.requests import Request
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
HMAC_SECRET = "test-secret-for-hmac-body-binding-packet-p1a"
|
|
|
|
|
|
def _make_scope(method: str, path: str, headers: dict, client_host: str = "1.2.3.4"):
|
|
"""Build a minimal ASGI scope for a Starlette Request."""
|
|
raw_headers = [(k.lower().encode(), str(v).encode()) for k, v in headers.items()]
|
|
return {
|
|
"type": "http",
|
|
"method": method.upper(),
|
|
"path": path,
|
|
"headers": raw_headers,
|
|
"query_string": b"",
|
|
"root_path": "",
|
|
"server": (client_host, 80),
|
|
"client": (client_host, 12345),
|
|
}
|
|
|
|
|
|
def _make_receive(body: bytes = b""):
|
|
"""Build an ASGI receive callable that returns *body*."""
|
|
async def receive():
|
|
return {"type": "http.request", "body": body}
|
|
return receive
|
|
|
|
|
|
def _sign(method: str, path: str, body: bytes = b"",
|
|
secret: str = HMAC_SECRET, ts: int | None = None,
|
|
nonce: str | None = None) -> dict[str, str]:
|
|
"""Produce valid X-SB-* auth headers with body binding."""
|
|
ts_str = str(ts if ts is not None else int(time.time()))
|
|
nonce = nonce or secrets.token_hex(16)
|
|
body_digest = hashlib.sha256(body).hexdigest()
|
|
message = f"{method.upper()}|{path}|{ts_str}|{nonce}|{body_digest}"
|
|
sig = hmac_mod.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
|
|
return {
|
|
"X-SB-Timestamp": ts_str,
|
|
"X-SB-Nonce": nonce,
|
|
"X-SB-Signature": sig,
|
|
}
|
|
|
|
|
|
def _make_request(method: str, path: str, headers: dict,
|
|
body: bytes = b"", client_host: str = "1.2.3.4") -> Request:
|
|
scope = _make_scope(method, path, headers, client_host)
|
|
return Request(scope, _make_receive(body))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _patch_hmac_secret(monkeypatch):
|
|
"""Ensure _openclaw_hmac_secret() returns our test secret."""
|
|
import auth
|
|
monkeypatch.setattr(auth, "_openclaw_hmac_secret", lambda: HMAC_SECRET)
|
|
# Clear nonce cache between tests to avoid cross-test interference.
|
|
auth._openclaw_nonce_cache.clear()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestBodyBinding:
|
|
"""Body-bearing requests must bind the body in the HMAC."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_with_matching_body_accepted(self):
|
|
from auth import _verify_openclaw_hmac
|
|
|
|
body = b'{"cmd":"get_summary","args":{}}'
|
|
headers = _sign("POST", "/api/ai/channel/command", body)
|
|
req = _make_request("POST", "/api/ai/channel/command", headers, body)
|
|
assert await _verify_openclaw_hmac(req) is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_with_tampered_body_rejected(self):
|
|
from auth import _verify_openclaw_hmac
|
|
|
|
original_body = b'{"cmd":"get_summary","args":{}}'
|
|
tampered_body = b'{"cmd":"place_pin","args":{"lat":0,"lng":0,"label":"evil"}}'
|
|
# Sign with original body, send tampered body
|
|
headers = _sign("POST", "/api/ai/channel/command", original_body)
|
|
req = _make_request("POST", "/api/ai/channel/command", headers, tampered_body)
|
|
assert await _verify_openclaw_hmac(req) is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_put_with_tampered_body_rejected(self):
|
|
from auth import _verify_openclaw_hmac
|
|
|
|
original_body = b'{"preset":"fast"}'
|
|
tampered_body = b'{"preset":"off"}'
|
|
headers = _sign("PUT", "/api/ai/timemachine/config", original_body)
|
|
req = _make_request("PUT", "/api/ai/timemachine/config", headers, tampered_body)
|
|
assert await _verify_openclaw_hmac(req) is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_patch_with_tampered_body_rejected(self):
|
|
from auth import _verify_openclaw_hmac
|
|
|
|
original_body = b'{"field":"value"}'
|
|
tampered_body = b'{"field":"evil"}'
|
|
headers = _sign("PATCH", "/api/ai/some-endpoint", original_body)
|
|
req = _make_request("PATCH", "/api/ai/some-endpoint", headers, tampered_body)
|
|
assert await _verify_openclaw_hmac(req) is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_body_accepted_when_signed_as_empty(self):
|
|
from auth import _verify_openclaw_hmac
|
|
|
|
headers = _sign("POST", "/api/ai/channel/poll", b"")
|
|
req = _make_request("POST", "/api/ai/channel/poll", headers, b"")
|
|
assert await _verify_openclaw_hmac(req) is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_body_injected_into_bodyless_signature_rejected(self):
|
|
"""Signing with empty body but sending a body must fail."""
|
|
from auth import _verify_openclaw_hmac
|
|
|
|
headers = _sign("POST", "/api/ai/channel/poll", b"")
|
|
injected = b'{"malicious": true}'
|
|
req = _make_request("POST", "/api/ai/channel/poll", headers, injected)
|
|
assert await _verify_openclaw_hmac(req) is False
|
|
|
|
|
|
class TestBodylessRequests:
|
|
"""GET/DELETE (no body) must still pass auth."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_no_body_accepted(self):
|
|
from auth import _verify_openclaw_hmac
|
|
|
|
headers = _sign("GET", "/api/ai/status")
|
|
req = _make_request("GET", "/api/ai/status", headers)
|
|
assert await _verify_openclaw_hmac(req) is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_no_body_accepted(self):
|
|
from auth import _verify_openclaw_hmac
|
|
|
|
headers = _sign("DELETE", "/api/ai/pins")
|
|
req = _make_request("DELETE", "/api/ai/pins", headers)
|
|
assert await _verify_openclaw_hmac(req) is True
|
|
|
|
|
|
class TestNonceReplay:
|
|
"""Nonce replay protection must still work after body-binding changes."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_replayed_nonce_rejected(self):
|
|
from auth import _verify_openclaw_hmac
|
|
|
|
nonce = secrets.token_hex(16)
|
|
body = b'{"cmd":"get_summary","args":{}}'
|
|
headers = _sign("POST", "/api/ai/channel/command", body, nonce=nonce)
|
|
req1 = _make_request("POST", "/api/ai/channel/command", headers, body)
|
|
assert await _verify_openclaw_hmac(req1) is True
|
|
|
|
# Replay same nonce — must be rejected
|
|
headers2 = _sign("POST", "/api/ai/channel/command", body, nonce=nonce)
|
|
req2 = _make_request("POST", "/api/ai/channel/command", headers2, body)
|
|
assert await _verify_openclaw_hmac(req2) is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_short_nonce_rejected(self):
|
|
from auth import _verify_openclaw_hmac
|
|
|
|
body = b'{"cmd":"get_summary"}'
|
|
headers = _sign("POST", "/api/ai/channel/command", body, nonce="short")
|
|
# Override nonce to be too short
|
|
headers["X-SB-Nonce"] = "short"
|
|
req = _make_request("POST", "/api/ai/channel/command", headers, body)
|
|
assert await _verify_openclaw_hmac(req) is False
|
|
|
|
|
|
class TestTimestampFreshness:
|
|
"""Timestamp freshness checks must still work."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_timestamp_rejected(self):
|
|
from auth import _verify_openclaw_hmac
|
|
|
|
stale_ts = int(time.time()) - 600 # 10 minutes ago
|
|
body = b'{"cmd":"get_summary"}'
|
|
headers = _sign("POST", "/api/ai/channel/command", body, ts=stale_ts)
|
|
req = _make_request("POST", "/api/ai/channel/command", headers, body)
|
|
assert await _verify_openclaw_hmac(req) is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_future_timestamp_rejected(self):
|
|
from auth import _verify_openclaw_hmac
|
|
|
|
future_ts = int(time.time()) + 600 # 10 minutes from now
|
|
body = b'{"cmd":"get_summary"}'
|
|
headers = _sign("POST", "/api/ai/channel/command", body, ts=future_ts)
|
|
req = _make_request("POST", "/api/ai/channel/command", headers, body)
|
|
assert await _verify_openclaw_hmac(req) is False
|
|
|
|
|
|
class TestMissingSecret:
|
|
"""No secret configured => always reject."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_secret_rejects(self, monkeypatch):
|
|
import auth
|
|
monkeypatch.setattr(auth, "_openclaw_hmac_secret", lambda: "")
|
|
from auth import _verify_openclaw_hmac
|
|
|
|
body = b'{"cmd":"get_summary"}'
|
|
headers = _sign("POST", "/api/ai/channel/command", body)
|
|
req = _make_request("POST", "/api/ai/channel/command", headers, body)
|
|
assert await _verify_openclaw_hmac(req) is False
|
|
|
|
|
|
class TestWrongSecret:
|
|
"""Wrong secret => rejected even with valid body binding."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wrong_secret_rejected(self):
|
|
from auth import _verify_openclaw_hmac
|
|
|
|
body = b'{"cmd":"get_summary"}'
|
|
headers = _sign("POST", "/api/ai/channel/command", body, secret="wrong-secret")
|
|
req = _make_request("POST", "/api/ai/channel/command", headers, body)
|
|
assert await _verify_openclaw_hmac(req) is False
|