mirror of
https://github.com/BigBodyCobain/Shadowbroker.git
synced 2026-08-18 08:27:18 +02:00
Fix Docker OpenClaw HMAC persistence and privacy-core build warnings.
Persist OPENCLAW_HMAC_SECRET to data/openclaw.env so empty Docker env vars no longer block auth after UI bootstrap. Add verify_hmac.py, silence Rust warnings (#423), and document Docker signing (#424). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2988,6 +2988,12 @@ def _write_env_value(key: str, value: str) -> None:
|
|||||||
# Also set in current process env so Settings picks it up
|
# Also set in current process env so Settings picks it up
|
||||||
os.environ[key] = value
|
os.environ[key] = value
|
||||||
|
|
||||||
|
try:
|
||||||
|
from services.api_settings import persist_openclaw_env_value
|
||||||
|
persist_openclaw_env_value(key, value)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Agent Identity Management (Ed25519 keypair — used for mesh signing;
|
# Agent Identity Management (Ed25519 keypair — used for mesh signing;
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ if not DATA_DIR.is_absolute():
|
|||||||
OPERATOR_KEYS_ENV_PATH = Path(
|
OPERATOR_KEYS_ENV_PATH = Path(
|
||||||
os.environ.get("SHADOWBROKER_OPERATOR_KEYS_ENV", str(DATA_DIR / "operator_api_keys.env"))
|
os.environ.get("SHADOWBROKER_OPERATOR_KEYS_ENV", str(DATA_DIR / "operator_api_keys.env"))
|
||||||
)
|
)
|
||||||
|
OPENCLAW_ENV_PATH = Path(
|
||||||
|
os.environ.get("SHADOWBROKER_OPENCLAW_ENV", str(DATA_DIR / "openclaw.env"))
|
||||||
|
)
|
||||||
|
OPENCLAW_PERSISTED_KEYS = frozenset({"OPENCLAW_HMAC_SECRET", "OPENCLAW_ACCESS_TIER"})
|
||||||
_ENV_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
_ENV_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -285,6 +289,34 @@ def load_persisted_api_keys_into_environ() -> None:
|
|||||||
os.environ[key] = value
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def load_persisted_openclaw_into_environ() -> None:
|
||||||
|
"""Load OpenClaw secrets from the data volume when env is unset/empty.
|
||||||
|
|
||||||
|
Docker Compose often injects ``OPENCLAW_HMAC_SECRET=`` as an empty string,
|
||||||
|
which blocks pydantic from reading backend/.env. Persisting bootstrap output
|
||||||
|
under ``data/openclaw.env`` (on the backend_data volume) keeps remote HMAC
|
||||||
|
working across container restarts (#424).
|
||||||
|
"""
|
||||||
|
persisted = _parse_env_file(OPENCLAW_ENV_PATH)
|
||||||
|
if not persisted.get("OPENCLAW_HMAC_SECRET"):
|
||||||
|
# One-time migration from legacy backend/.env writes inside the image.
|
||||||
|
persisted = {**_parse_env_file(ENV_PATH), **persisted}
|
||||||
|
|
||||||
|
for key, value in persisted.items():
|
||||||
|
if key not in OPENCLAW_PERSISTED_KEYS:
|
||||||
|
continue
|
||||||
|
cleaned = str(value or "").strip()
|
||||||
|
if cleaned and not str(os.environ.get(key, "")).strip():
|
||||||
|
os.environ[key] = cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def persist_openclaw_env_value(key: str, value: str) -> None:
|
||||||
|
"""Persist OpenClaw runtime settings to the data volume."""
|
||||||
|
if key not in OPENCLAW_PERSISTED_KEYS:
|
||||||
|
return
|
||||||
|
_write_env_values(OPENCLAW_ENV_PATH, {key: value})
|
||||||
|
|
||||||
|
|
||||||
def get_env_path_info() -> dict:
|
def get_env_path_info() -> dict:
|
||||||
"""Return absolute paths for the backend .env and .env.example template.
|
"""Return absolute paths for the backend .env and .env.example template.
|
||||||
|
|
||||||
@@ -305,6 +337,10 @@ def get_env_path_info() -> dict:
|
|||||||
"operator_keys_env_path_exists": OPERATOR_KEYS_ENV_PATH.exists(),
|
"operator_keys_env_path_exists": OPERATOR_KEYS_ENV_PATH.exists(),
|
||||||
"operator_keys_env_path_writable": os.access(OPERATOR_KEYS_ENV_PATH.parent, os.W_OK)
|
"operator_keys_env_path_writable": os.access(OPERATOR_KEYS_ENV_PATH.parent, os.W_OK)
|
||||||
and (not OPERATOR_KEYS_ENV_PATH.exists() or os.access(OPERATOR_KEYS_ENV_PATH, os.W_OK)),
|
and (not OPERATOR_KEYS_ENV_PATH.exists() or os.access(OPERATOR_KEYS_ENV_PATH, os.W_OK)),
|
||||||
|
"openclaw_env_path": str(OPENCLAW_ENV_PATH.resolve()),
|
||||||
|
"openclaw_env_path_exists": OPENCLAW_ENV_PATH.exists(),
|
||||||
|
"openclaw_env_path_writable": os.access(OPENCLAW_ENV_PATH.parent, os.W_OK)
|
||||||
|
and (not OPENCLAW_ENV_PATH.exists() or os.access(OPENCLAW_ENV_PATH, os.W_OK)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -370,8 +370,12 @@ class Settings(BaseSettings):
|
|||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
try:
|
try:
|
||||||
from services.api_settings import load_persisted_api_keys_into_environ
|
from services.api_settings import (
|
||||||
|
load_persisted_api_keys_into_environ,
|
||||||
|
load_persisted_openclaw_into_environ,
|
||||||
|
)
|
||||||
load_persisted_api_keys_into_environ()
|
load_persisted_api_keys_into_environ()
|
||||||
|
load_persisted_openclaw_into_environ()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return Settings()
|
return Settings()
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import hashlib
|
||||||
|
import hmac as hmac_mod
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
|
||||||
|
def test_persisted_openclaw_secret_loads_when_docker_env_blank(tmp_path, monkeypatch):
|
||||||
|
from services import api_settings
|
||||||
|
from services.config import get_settings
|
||||||
|
|
||||||
|
openclaw_env = tmp_path / "openclaw.env"
|
||||||
|
openclaw_env.write_text('OPENCLAW_HMAC_SECRET="persisted-hmac-secret"\n', encoding="utf-8")
|
||||||
|
monkeypatch.setattr(api_settings, "OPENCLAW_ENV_PATH", openclaw_env)
|
||||||
|
monkeypatch.setenv("OPENCLAW_HMAC_SECRET", "")
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
api_settings.load_persisted_openclaw_into_environ()
|
||||||
|
|
||||||
|
assert os.environ["OPENCLAW_HMAC_SECRET"] == "persisted-hmac-secret"
|
||||||
|
assert get_settings().OPENCLAW_HMAC_SECRET == "persisted-hmac-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_openclaw_env_value_writes_data_volume(tmp_path, monkeypatch):
|
||||||
|
from services import api_settings
|
||||||
|
|
||||||
|
openclaw_env = tmp_path / "openclaw.env"
|
||||||
|
monkeypatch.setattr(api_settings, "OPENCLAW_ENV_PATH", openclaw_env)
|
||||||
|
|
||||||
|
api_settings.persist_openclaw_env_value("OPENCLAW_HMAC_SECRET", "minted-secret")
|
||||||
|
|
||||||
|
assert 'OPENCLAW_HMAC_SECRET="minted-secret"' in openclaw_env.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hmac_verify_accepts_canonical_json_from_docker_host(monkeypatch):
|
||||||
|
import auth
|
||||||
|
|
||||||
|
secret = "docker-hmac-test-secret"
|
||||||
|
monkeypatch.setattr(auth, "_openclaw_hmac_secret", lambda: secret)
|
||||||
|
|
||||||
|
body = json.dumps({"args": {}, "cmd": "channel_status"}, separators=(",", ":"), sort_keys=True).encode(
|
||||||
|
"utf-8"
|
||||||
|
)
|
||||||
|
ts = str(int(time.time()))
|
||||||
|
nonce = secrets.token_hex(16)
|
||||||
|
digest = hashlib.sha256(body).hexdigest()
|
||||||
|
message = f"POST|/api/ai/channel/command|{ts}|{nonce}|{digest}"
|
||||||
|
signature = hmac_mod.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
return {"type": "http.request", "body": body}
|
||||||
|
|
||||||
|
req = Request(
|
||||||
|
{
|
||||||
|
"type": "http",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/api/ai/channel/command",
|
||||||
|
"headers": [
|
||||||
|
(b"x-sb-timestamp", ts.encode()),
|
||||||
|
(b"x-sb-nonce", nonce.encode()),
|
||||||
|
(b"x-sb-signature", signature.encode()),
|
||||||
|
],
|
||||||
|
"query_string": b"",
|
||||||
|
"root_path": "",
|
||||||
|
"server": ("172.17.0.1", 80),
|
||||||
|
"client": ("172.17.0.1", 12345),
|
||||||
|
},
|
||||||
|
receive,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await auth._verify_openclaw_hmac(req) is True
|
||||||
@@ -88,6 +88,19 @@ For compatibility with older snippets, `SHADOWBROKER_KEY` is also accepted by
|
|||||||
the client as the same HMAC signing secret. Prefer `SHADOWBROKER_HMAC_SECRET`
|
the client as the same HMAC signing secret. Prefer `SHADOWBROKER_HMAC_SECRET`
|
||||||
for new setups.
|
for new setups.
|
||||||
|
|
||||||
|
**Docker Compose:** host-side agents (`localhost:8000` from the Kali/macOS host)
|
||||||
|
are not loopback inside the backend container — HMAC is required. After
|
||||||
|
**Connect OpenClaw → Bootstrap → Reveal**, the secret is persisted under
|
||||||
|
`data/openclaw.env` on the `backend_data` volume. Restart the backend once,
|
||||||
|
then verify with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python openclaw-skills/shadowbroker/verify_hmac.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Hand-rolled signers must hash the **exact** POST bytes. Use compact JSON:
|
||||||
|
`json.dumps(payload, separators=(",", ":"), sort_keys=True)`.
|
||||||
|
|
||||||
### SSE Stream (Preferred — Low-Latency Push)
|
### SSE Stream (Preferred — Low-Latency Push)
|
||||||
|
|
||||||
Open the SSE stream **first** and keep it open for the session. The server pushes
|
Open the SSE stream **first** and keep it open for the session. The server pushes
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Verify OpenClaw HMAC auth against a running ShadowBroker backend.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
export SHADOWBROKER_URL=http://127.0.0.1:8000
|
||||||
|
export SHADOWBROKER_HMAC_SECRET=<from Connect OpenClaw modal>
|
||||||
|
python verify_hmac.py
|
||||||
|
|
||||||
|
Signs the same canonical JSON body as openclaw-skills/shadowbroker/sb_query.py.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def sign(method: str, path: str, body: bytes, secret: str) -> dict[str, str]:
|
||||||
|
ts = str(int(time.time()))
|
||||||
|
nonce = secrets.token_hex(16)
|
||||||
|
digest = hashlib.sha256(body).hexdigest()
|
||||||
|
message = f"{method.upper()}|{path}|{ts}|{nonce}|{digest}"
|
||||||
|
signature = hmac.new(secret.encode(), message.encode(), hashlib.sha256).hexdigest()
|
||||||
|
return {
|
||||||
|
"X-SB-Timestamp": ts,
|
||||||
|
"X-SB-Nonce": nonce,
|
||||||
|
"X-SB-Signature": signature,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
base = os.environ.get("SHADOWBROKER_URL", "http://127.0.0.1:8000").rstrip("/")
|
||||||
|
secret = os.environ.get("SHADOWBROKER_HMAC_SECRET", "").strip()
|
||||||
|
if not secret:
|
||||||
|
print("Set SHADOWBROKER_HMAC_SECRET to the value from Connect OpenClaw.", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
path = "/api/ai/channel/command"
|
||||||
|
payload = {"cmd": "channel_status", "args": {}}
|
||||||
|
body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||||
|
headers = sign("POST", path, body, secret)
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{base}{path}",
|
||||||
|
data=body,
|
||||||
|
headers=headers,
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||||
|
text = resp.read().decode("utf-8", errors="replace")
|
||||||
|
print(f"HTTP {resp.status}")
|
||||||
|
print(text)
|
||||||
|
return 0
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="replace")
|
||||||
|
print(f"HTTP {exc.code}")
|
||||||
|
print(detail)
|
||||||
|
if exc.code == 403:
|
||||||
|
print(
|
||||||
|
"\nTips:\n"
|
||||||
|
"- Bootstrap + Reveal the HMAC secret in AI Intel → Connect OpenClaw\n"
|
||||||
|
"- Use the exact secret (no extra whitespace)\n"
|
||||||
|
"- Sign compact JSON: json.dumps(..., separators=(',', ':'), sort_keys=True)\n"
|
||||||
|
"- Hit the backend port directly (e.g. :8000), not the Next.js :3000 proxy\n"
|
||||||
|
"- After upgrading, restart the backend so data/openclaw.env is loaded",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -98,7 +98,7 @@ struct GroupState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct CommitState {
|
struct CommitState {
|
||||||
family_id: FamilyId,
|
_family_id: FamilyId,
|
||||||
commit_message: Vec<u8>,
|
commit_message: Vec<u8>,
|
||||||
welcome_messages: Vec<Vec<u8>>,
|
welcome_messages: Vec<Vec<u8>>,
|
||||||
joined_group_handles: Vec<GroupHandle>,
|
joined_group_handles: Vec<GroupHandle>,
|
||||||
@@ -282,7 +282,7 @@ fn make_client(label: &[u8]) -> Result<(PrivacyClient, SigningIdentity, Vec<u8>)
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn make_client_from_parts(
|
fn make_client_from_parts(
|
||||||
label: &[u8],
|
_label: &[u8],
|
||||||
signing_identity: SigningIdentity,
|
signing_identity: SigningIdentity,
|
||||||
signer_secret_bytes: &[u8],
|
signer_secret_bytes: &[u8],
|
||||||
) -> Result<PrivacyClient, String> {
|
) -> Result<PrivacyClient, String> {
|
||||||
@@ -542,7 +542,7 @@ pub fn add_member(
|
|||||||
commits().lock().expect("commits mutex poisoned").insert(
|
commits().lock().expect("commits mutex poisoned").insert(
|
||||||
commit_handle,
|
commit_handle,
|
||||||
CommitState {
|
CommitState {
|
||||||
family_id,
|
_family_id: family_id,
|
||||||
commit_message: commit_output
|
commit_message: commit_output
|
||||||
.commit_message
|
.commit_message
|
||||||
.mls_encode_to_vec()
|
.mls_encode_to_vec()
|
||||||
@@ -612,7 +612,7 @@ pub fn remove_member(
|
|||||||
commits().lock().expect("commits mutex poisoned").insert(
|
commits().lock().expect("commits mutex poisoned").insert(
|
||||||
commit_handle,
|
commit_handle,
|
||||||
CommitState {
|
CommitState {
|
||||||
family_id,
|
_family_id: family_id,
|
||||||
commit_message: commit_output
|
commit_message: commit_output
|
||||||
.commit_message
|
.commit_message
|
||||||
.mls_encode_to_vec()
|
.mls_encode_to_vec()
|
||||||
|
|||||||
Reference in New Issue
Block a user