diff --git a/backend/routers/ai_intel.py b/backend/routers/ai_intel.py index 32ffeae..2594bb4 100644 --- a/backend/routers/ai_intel.py +++ b/backend/routers/ai_intel.py @@ -2988,6 +2988,12 @@ def _write_env_value(key: str, value: str) -> None: # Also set in current process env so Settings picks it up 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; diff --git a/backend/services/api_settings.py b/backend/services/api_settings.py index 7f88616..30cfe8f 100644 --- a/backend/services/api_settings.py +++ b/backend/services/api_settings.py @@ -19,6 +19,10 @@ if not DATA_DIR.is_absolute(): OPERATOR_KEYS_ENV_PATH = Path( 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_]*$") # --------------------------------------------------------------------------- @@ -285,6 +289,34 @@ def load_persisted_api_keys_into_environ() -> None: 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: """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_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)), + "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)), } diff --git a/backend/services/config.py b/backend/services/config.py index a360ea2..0bedeeb 100644 --- a/backend/services/config.py +++ b/backend/services/config.py @@ -370,8 +370,12 @@ class Settings(BaseSettings): @lru_cache def get_settings() -> Settings: 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_openclaw_into_environ() except Exception: pass return Settings() diff --git a/backend/tests/test_openclaw_persisted_env.py b/backend/tests/test_openclaw_persisted_env.py new file mode 100644 index 0000000..57aeda7 --- /dev/null +++ b/backend/tests/test_openclaw_persisted_env.py @@ -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 diff --git a/openclaw-skills/shadowbroker/SKILL.md b/openclaw-skills/shadowbroker/SKILL.md index 4bd6062..3cf9b6e 100644 --- a/openclaw-skills/shadowbroker/SKILL.md +++ b/openclaw-skills/shadowbroker/SKILL.md @@ -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` 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) Open the SSE stream **first** and keep it open for the session. The server pushes diff --git a/openclaw-skills/shadowbroker/verify_hmac.py b/openclaw-skills/shadowbroker/verify_hmac.py new file mode 100644 index 0000000..9a67a46 --- /dev/null +++ b/openclaw-skills/shadowbroker/verify_hmac.py @@ -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= + 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()) diff --git a/privacy-core/src/lib.rs b/privacy-core/src/lib.rs index 9b6c35d..d3e487b 100644 --- a/privacy-core/src/lib.rs +++ b/privacy-core/src/lib.rs @@ -98,7 +98,7 @@ struct GroupState { } struct CommitState { - family_id: FamilyId, + _family_id: FamilyId, commit_message: Vec, welcome_messages: Vec>, joined_group_handles: Vec, @@ -282,7 +282,7 @@ fn make_client(label: &[u8]) -> Result<(PrivacyClient, SigningIdentity, Vec) } fn make_client_from_parts( - label: &[u8], + _label: &[u8], signing_identity: SigningIdentity, signer_secret_bytes: &[u8], ) -> Result { @@ -542,7 +542,7 @@ pub fn add_member( commits().lock().expect("commits mutex poisoned").insert( commit_handle, CommitState { - family_id, + _family_id: family_id, commit_message: commit_output .commit_message .mls_encode_to_vec() @@ -612,7 +612,7 @@ pub fn remove_member( commits().lock().expect("commits mutex poisoned").insert( commit_handle, CommitState { - family_id, + _family_id: family_id, commit_message: commit_output .commit_message .mls_encode_to_vec()