From d069d3a583569650689093449c65c7b47e7056de Mon Sep 17 00:00:00 2001 From: NATIVE Date: Fri, 11 Sep 2026 01:36:35 +0200 Subject: [PATCH 1/3] feat: add encrypted multiplayer workspaces --- .env.example | 9 + README.md | 36 ++ docs/PRIVATE_RELAY.md | 83 +++++ pyproject.toml | 1 + scripts/smoke_private_relay.py | 92 +++++ src/h1dr4_attackgraph/memory.py | 164 +++++++-- src/h1dr4_attackgraph/relay.py | 537 +++++++++++++++++++++++++++++ src/h1dr4_attackgraph/relay_cli.py | 118 +++++++ src/h1dr4_attackgraph/server.py | 148 +++++++- src/h1dr4_attackgraph/service.py | 5 +- tests/test_mcp.py | 9 +- tests/test_relay.py | 233 +++++++++++++ 12 files changed, 1394 insertions(+), 41 deletions(-) create mode 100644 docs/PRIVATE_RELAY.md create mode 100644 scripts/smoke_private_relay.py create mode 100644 src/h1dr4_attackgraph/relay.py create mode 100644 src/h1dr4_attackgraph/relay_cli.py create mode 100644 tests/test_relay.py diff --git a/.env.example b/.env.example index 80f4b84..901d1e1 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,15 @@ ATTACKGRAPH_ORIGIN=http://localhost:3000 # MCP clients created by the dashboard receive their own revocable value. # ATTACKGRAPH_AGENT_TOKEN=atk_agent_... +# Optional encrypted multiplayer relay. Local Sibyl remains authoritative. +ATTACKGRAPH_RELAY_URL=https://h1dr4.dev/api/v1/attackgraph +ATTACKGRAPH_RELAY_STATE_PATH=.attackgraph/relay.json +# Comma-separated extra relay hostnames. HTTPS is mandatory except on loopback. +# ATTACKGRAPH_RELAY_ALLOWED_HOSTS=relay.example.com +# Required only for hosting a new workspace on a relay that restricts creation. +# ATTACKGRAPH_RELAY_BOOTSTRAP_TOKEN=... +# ATTACKGRAPH_RELAY_ACTOR_NAME=WEB-01 + # Optional H3RETIK execution gate. Quoting and planning do not require these. # ATTACKGRAPH_EXECUTION_APPROVAL_CODE=replace-with-a-human-generated-one-time-code # ATTACKGRAPH_H3RETIK_ATTESTATION_TOKEN=separate-adapter-only-secret diff --git a/README.md b/README.md index c4ef133..c2b8981 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,42 @@ to one combined operation. Session bindings are operational metadata only; H3RETIK wallet and access credentials stay in environment variables outside the dashboard. +### Private multiplayer workspaces + +AttackGraph can synchronize an engagement between operators without uploading +the Sibyl database or plaintext intelligence. Each device keeps its own local +Sibyl. The H1DR4 relay stores only AES-256-GCM encrypted, Ed25519-signed +workspace snapshots, opaque identifiers, cursors, and membership metadata. + +Host an existing engagement and create a one-use operator invite: + +```bash +uv run attackgraph host eng-example --name WEB-01 +uv run attackgraph invite eng-example --role operator --hours 24 +``` + +On another machine, using the same absolute database and relay-state paths as +its MCP configuration: + +```bash +uv run attackgraph join 'h1dr4-ag1:REDACTED' --name AUTH-02 +uv run attackgraph status +``` + +The same host, invite, join, sync, member-list, and revoke operations are +available as MCP tools, so a fresh Codex can join without leaving the agent +workflow. + +The invite code contains key material and must be treated as a secret. Relay +access tokens and workspace keys are kept in the mode-0600 file selected by +`ATTACKGRAPH_RELAY_STATE_PATH`; they are never written to Sibyl or returned by +an MCP status tool. After joining, ordinary MCP reads pull remote events before +building a brief, while writes merge and publish a new encrypted snapshot. +Local reads and writes remain available if the relay is temporarily offline. + +See [docs/PRIVATE_RELAY.md](docs/PRIVATE_RELAY.md) for the protocol, deployment +boundary, revocation limitation, and two-client smoke test. + For a locked deployment, set the relying-party values explicitly: ```bash diff --git a/docs/PRIVATE_RELAY.md b/docs/PRIVATE_RELAY.md new file mode 100644 index 0000000..f9b625a --- /dev/null +++ b/docs/PRIVATE_RELAY.md @@ -0,0 +1,83 @@ +# Private workspace relay + +The relay is a store-and-forward coordination service, not a hosted Sibyl. +Every authorized operator retains a local Sibyl database and uses the ordinary +local AttackGraph MCP server. AttackGraph synchronizes encrypted workspace +snapshots over HTTPS before reads and after writes. + +```text +Codex A -> local MCP -> local Sibyl A -- encrypted snapshots --+ + | + H1DR4 relay + | +Codex B -> local MCP -> local Sibyl B -- encrypted snapshots --+ +``` + +## Cryptographic boundary + +- One random 256-bit AES-GCM key encrypts a workspace. +- Every device has an Ed25519 signing key. +- Envelope additional authenticated data binds the workspace, event, actor, + and creation time. +- The relay verifies the ciphertext hash, signature, authenticated actor, role, + and workspace membership before accepting an event. +- The relay never receives the workspace key or decrypted snapshot. +- A one-use invite wraps the workspace key under a separate random invite + secret. The relay receives only a hash-derived redemption verifier. + +Relay operators can still observe metadata such as event timing, ciphertext +size, opaque workspace identifiers, and membership count. This is not a +metadata-hiding protocol. + +## Synchronization contract + +The SQLite database is never copied between machines. Each relay event contains +an encrypted `h1dr4.workspace.snapshot.v1` materialization. The receiving client +merges graph records by stable ID, preserves list provenance, prefers newer +updates, and prefers higher assurance when telemetry is promoted from asserted +to attested or verified. + +Relay writes are idempotent by event ID. Reusing an event ID with different +ciphertext is rejected. A cursor gives every member deterministic catch-up. + +## Enrollment + +1. The owner opens an engagement locally. +2. `attackgraph host` creates the relay workspace and publishes the first + encrypted snapshot. +3. `attackgraph invite` creates a one-use, expiring invite. +4. The second operator runs `attackgraph join` locally and receives the wrapped + key plus an independent revocable relay credential. +5. Its first pull reconstructs the engagement in its own Sibyl database. + +The owner can inspect and revoke membership with `attackgraph members` and +`attackgraph revoke`. Equivalent MCP tools let Codex perform the complete +enrollment flow directly. + +Never paste an invite code into a recorded prompt or commit it. Deliver it to +the intended operator over an authenticated private channel. + +## Revocation + +Revocation immediately blocks future relay reads and writes for the member's +credential. It cannot erase intelligence already decrypted on that member's +device. Production removal must also rotate the workspace key for subsequent +events; historical access remains an inherent property of collaboration. + +## Development smoke + +Start the in-memory H1DR4 relay fixture from the H1DR4 API checkout: + +```bash +npm run dev:attackgraph-relay +``` + +Then, from this repository: + +```bash +uv run python scripts/smoke_private_relay.py +``` + +The smoke creates two isolated Sibyl databases, joins the second operator, +publishes an exhausted attack path, recalls it from the first database, and +asserts that neither the target nor the path appears in relay-visible JSON. diff --git a/pyproject.toml b/pyproject.toml index 5fa3210..cff24c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dev = [ [project.scripts] h1dr4-attackgraph = "h1dr4_attackgraph.server:main" h1dr4-attackgraph-dashboard = "h1dr4_attackgraph.dashboard:main" +attackgraph = "h1dr4_attackgraph.relay_cli:main" [tool.hatch.build.targets.wheel] packages = ["src/h1dr4_attackgraph"] diff --git a/scripts/smoke_private_relay.py b/scripts/smoke_private_relay.py new file mode 100644 index 0000000..a67216b --- /dev/null +++ b/scripts/smoke_private_relay.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import argparse +import json +import os +import tempfile +from pathlib import Path + +import httpx + +from h1dr4_attackgraph.relay import AttackGraphRelayClient, RelayStateStore +from h1dr4_attackgraph.service import AttackGraphService + + +def main() -> None: + parser = argparse.ArgumentParser(description="Exercise a real relay with two isolated Sibyls.") + parser.add_argument("--relay", default="http://127.0.0.1:8790/v1/attackgraph") + args = parser.parse_args() + bootstrap = os.getenv("ATTACKGRAPH_RELAY_BOOTSTRAP_TOKEN", "local-development-only") + + with tempfile.TemporaryDirectory(prefix="attackgraph-relay-smoke-") as directory: + root = Path(directory) + owner_service = AttackGraphService( + db_path=root / "owner.db", operator_id="smoke-owner", identity=None + ) + engagement = owner_service.open_engagement( + title="Private relay smoke", + target="http://owned-fixture.internal", + mode="local_lab", + scope="Owned test fixture only", + target_allowlist=["http://owned-fixture.internal"], + ) + owner = AttackGraphRelayClient(RelayStateStore(root / "owner-relay.json")) + owner.host_workspace( + owner_service, + engagement["engagement_id"], + relay_url=args.relay, + actor_name="WEB-01", + bootstrap_token=bootstrap, + ) + invite = owner.create_invite(engagement["engagement_id"]) + + friend_service = AttackGraphService( + db_path=root / "friend.db", operator_id="smoke-friend", identity=None + ) + friend = AttackGraphRelayClient(RelayStateStore(root / "friend-relay.json")) + joined = friend.join_workspace( + friend_service, + invite["invite_code"], + actor_name="AUTH-02", + ) + friend_service.record_attempt( + engagement["engagement_id"], + approach="Known failed path from remote operator", + outcome="failed", + exhausted=True, + evidence={"status": 401}, + ) + friend.push_workspace(friend_service, engagement["engagement_id"]) + owner_pulled = owner.pull_workspace(owner_service, engagement["engagement_id"]) + brief = owner_service.brief(engagement["engagement_id"]) + + owner_workspace = owner.state.workspace(engagement["engagement_id"]) + response = httpx.get( + f"{args.relay}/workspaces/{engagement['engagement_id']}/events?after=0", + headers={"authorization": f"Bearer {owner_workspace['access_token']}"}, + timeout=20, + ) + response.raise_for_status() + relay_view = response.text + assert "Known failed path from remote operator" not in relay_view + assert "owned-fixture.internal" not in relay_view + assert brief["exhausted_paths"][-1]["approach"] == "Known failed path from remote operator" + + print( + json.dumps( + { + "ok": True, + "workspace_id": engagement["engagement_id"], + "friend_pulled_events": joined["pulled_events"], + "owner_pulled_events": owner_pulled, + "relay_plaintext_visible": False, + "exhausted_path_recalled": True, + }, + indent=2, + sort_keys=True, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/src/h1dr4_attackgraph/memory.py b/src/h1dr4_attackgraph/memory.py index b907cd9..bc93ab7 100644 --- a/src/h1dr4_attackgraph/memory.py +++ b/src/h1dr4_attackgraph/memory.py @@ -123,6 +123,124 @@ def get_engagement(self, engagement_id: str) -> Engagement: def list_engagements(self) -> list[dict[str, Any]]: return [entity["body"] for entity in self.client.list_entities("engagement", limit=100)] + def export_workspace(self, engagement_id: str) -> dict[str, Any]: + """Return the deterministic materialized workspace shared through an encrypted relay.""" + + return { + "schema": "h1dr4.workspace.snapshot.v1", + "engagement": self.get_engagement(engagement_id).to_dict(), + "graph": self.get_graph(engagement_id), + "exported_at": _now(), + } + + def import_workspace(self, snapshot: dict[str, Any]) -> dict[str, Any]: + """Idempotently merge a decrypted remote snapshot into this local Sibyl.""" + + if snapshot.get("schema") != "h1dr4.workspace.snapshot.v1": + raise ValueError("workspace_snapshot_schema_unsupported") + engagement = snapshot.get("engagement") + incoming_graph = snapshot.get("graph") + if not isinstance(engagement, dict) or not isinstance(incoming_graph, dict): + raise ValueError("workspace_snapshot_invalid") + engagement_id = str(engagement.get("engagement_id") or "") + if not engagement_id: + raise ValueError("workspace_snapshot_engagement_required") + + try: + local_engagement = self.get_engagement(engagement_id).to_dict() + except EngagementNotFoundError: + local_engagement = None + if local_engagement: + immutable = ("target", "mode", "scope", "target_allowlist") + if any(local_engagement.get(key) != engagement.get(key) for key in immutable): + raise ValueError("workspace_snapshot_scope_conflict") + else: + self.client.set_entity( + "engagement", + engagement_id, + redact(engagement), + status="active", + ) + self.client.set_state(self._graph_key(engagement_id), _new_graph()) + + graph = self.get_graph(engagement_id) + merged = 0 + for collection in _new_graph(): + incoming_items = incoming_graph.get(collection) or [] + if not isinstance(incoming_items, list): + raise ValueError("workspace_snapshot_graph_invalid") + by_id = { + str(item.get("id")): item + for item in graph[collection] + if isinstance(item, dict) and item.get("id") + } + for incoming in incoming_items: + if not isinstance(incoming, dict) or not incoming.get("id"): + continue + item_id = str(incoming["id"]) + existing = by_id.get(item_id) + if existing is None: + copied = redact(incoming) + graph[collection].append(copied) + by_id[item_id] = copied + merged += 1 + continue + before = json.dumps(existing, sort_keys=True, separators=(",", ":")) + self._merge_workspace_item(existing, redact(incoming)) + after = json.dumps(existing, sort_keys=True, separators=(",", ":")) + if before != after: + merged += 1 + + graph["findings"] = [ + item for item in graph["observations"] if item.get("kind") == "finding" + ] + self._save_graph(engagement_id, graph) + self._event( + engagement_id, + "workspace_synced", + { + "merged_records": merged, + "remote_exported_at": snapshot.get("exported_at"), + }, + ) + return {"engagement_id": engagement_id, "merged_records": merged} + + @staticmethod + def _merge_workspace_item(existing: dict[str, Any], incoming: dict[str, Any]) -> None: + assurance_rank = {"asserted": 0, "attested": 1, "verified": 2} + time_fields = ( + "promoted_at", + "completed_at", + "last_seen_at", + "recorded_at", + "created_at", + ) + existing_time = max((str(existing.get(key) or "") for key in time_fields), default="") + incoming_time = max((str(incoming.get(key) or "") for key in time_fields), default="") + prefer_incoming = incoming_time >= existing_time + if assurance_rank.get(str(incoming.get("assurance") or ""), -1) > assurance_rank.get( + str(existing.get("assurance") or ""), -1 + ): + prefer_incoming = True + if prefer_incoming: + existing.update(incoming) + + for key in ( + "event_ids", + "entity_ids", + "finding_ids", + "job_ids", + "h3retik_session_ids", + ): + values = [*(existing.get(key) or []), *(incoming.get(key) or [])] + if values: + existing[key] = list(dict.fromkeys(str(value) for value in values if value)) + for key in ("attributes", "metadata", "evidence", "presentation", "proof"): + left = existing.get(key) + right = incoming.get(key) + if isinstance(left, dict) and isinstance(right, dict): + existing[key] = {**left, **right} if prefer_incoming else {**right, **left} + def get_graph(self, engagement_id: str) -> dict[str, list[dict[str, Any]]]: self.get_engagement(engagement_id) state = self.client.get_state(self._graph_key(engagement_id)) @@ -145,9 +263,7 @@ def _repair_telemetry_projection(self, graph: dict[str, list[dict[str, Any]]]) - event["artifact_id"] = str(artifact.get("id") or "") self._merge_telemetry_relationships(graph, event) - def record_telemetry( - self, engagement_id: str, event: dict[str, Any] - ) -> dict[str, Any]: + def record_telemetry(self, engagement_id: str, event: dict[str, Any]) -> dict[str, Any]: graph = self.get_graph(engagement_id) existing = next( ( @@ -291,8 +407,7 @@ def _materialize_telemetry( ) for relationship in event.get("relationships") or [] if "event" in {relationship.get("from"), relationship.get("to")} - and {relationship.get("from"), relationship.get("to")} - != {"event", "target"} + and {relationship.get("from"), relationship.get("to")} != {"event", "target"} ) ) if not entity_ids: @@ -378,7 +493,8 @@ def _upsert_telemetry_entities( if not entity_id: continue existing = next( - (item for item in graph["entities"] if item.get("id") == entity_id), None + (item for item in graph["entities"] if item.get("id") == entity_id), + None, ) event_ids = list(dict.fromkeys([*((existing or {}).get("event_ids") or []), event_id])) session_ids = list( @@ -419,8 +535,7 @@ def _upsert_telemetry_entities( "type": incoming.get("type"), "label": incoming.get("label") or entity_id, "layer": incoming.get("layer") or (existing or {}).get("layer", ""), - "parent_id": incoming.get("parent_id") - or (existing or {}).get("parent_id", ""), + "parent_id": incoming.get("parent_id") or (existing or {}).get("parent_id", ""), "metadata": metadata, "assurance": assurance, "actor": actor, @@ -491,9 +606,7 @@ def _materialize_telemetry_artifact( "h3retik_session_id": event.get("h3retik_session_id", ""), "job_id": proof.get("job_id", ""), "telemetry_event_id": event.get("id", ""), - "recorded_at": (existing or {}).get( - "recorded_at", event.get("recorded_at", _now()) - ), + "recorded_at": (existing or {}).get("recorded_at", event.get("recorded_at", _now())), } if existing: existing.update(payload) @@ -508,7 +621,10 @@ def _telemetry_record( if not record_id: return None for collection in ("observations", "attempts"): - match = next((item for item in graph[collection] if item.get("id") == record_id), None) + match = next( + (item for item in graph[collection] if item.get("id") == record_id), + None, + ) if match: return match return None @@ -564,24 +680,22 @@ def _merge_telemetry_relationships( if not entity_id: continue parent_id = str(entity.get("parent_id") or "target") - if entity.get("type") not in {"target", "agent", "session", "job", "technique"}: - relationships.append( - {"from": parent_id, "to": entity_id, "type": "contains"} - ) + if entity.get("type") not in { + "target", + "agent", + "session", + "job", + "technique", + }: + relationships.append({"from": parent_id, "to": entity_id, "type": "contains"}) record = self._telemetry_record(graph, record_id) for entity_id in (record or {}).get("entity_ids") or []: - relationships.append( - {"from": record_id, "to": entity_id, "type": "observed_on"} - ) + relationships.append({"from": record_id, "to": entity_id, "type": "observed_on"}) if artifact_id: for entity_id in artifact.get("entity_ids") or []: - relationships.append( - {"from": entity_id, "to": artifact_id, "type": "produced"} - ) + relationships.append({"from": entity_id, "to": artifact_id, "type": "produced"}) for finding_id in artifact.get("finding_ids") or []: - relationships.append( - {"from": artifact_id, "to": finding_id, "type": "supports"} - ) + relationships.append({"from": artifact_id, "to": finding_id, "type": "supports"}) for relationship in event.get("relationships") or []: source = event_id if relationship.get("from") == "event" else relationship.get("from") destination = event_id if relationship.get("to") == "event" else relationship.get("to") diff --git a/src/h1dr4_attackgraph/relay.py b/src/h1dr4_attackgraph/relay.py new file mode 100644 index 0000000..79505d5 --- /dev/null +++ b/src/h1dr4_attackgraph/relay.py @@ -0,0 +1,537 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import secrets +import uuid +from ipaddress import ip_address +from pathlib import Path +from typing import Any +from urllib.parse import urlsplit, urlunsplit + +import httpx +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + PublicFormat, +) + +from .service import AttackGraphService +from .workspace_sync import ( + WorkspaceEnvelope, + WorkspaceEnvelopeCodec, + generate_workspace_key, +) + +DEFAULT_RELAY_URL = "https://h1dr4.dev/api/v1/attackgraph" +INVITE_PREFIX = "h1dr4-ag1:" + + +def _b64(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode() + + +def _unb64(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _json_b64(value: dict[str, Any]) -> str: + return _b64(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()) + + +def _sha256(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _invite_wrap_key(secret: bytes) -> bytes: + return hashlib.sha256(b"h1dr4-attackgraph-invite-v1\0" + secret).digest() + + +def _redeem_proof(secret: bytes) -> str: + return _sha256(b"h1dr4-attackgraph-redeem-v1\0" + secret) + + +def _relay_url(value: str) -> str: + raw = str(value or "").strip() + parsed = urlsplit(raw) + hostname = (parsed.hostname or "").lower() + try: + loopback = hostname == "localhost" or ip_address(hostname).is_loopback + except ValueError: + loopback = False + allowed = {"h1dr4.dev", "localhost", "127.0.0.1", "::1"} + allowed.update( + item.strip().lower() + for item in os.getenv("ATTACKGRAPH_RELAY_ALLOWED_HOSTS", "").split(",") + if item.strip() + ) + if ( + not hostname + or hostname not in allowed + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + or (parsed.scheme != "https" and not (parsed.scheme == "http" and loopback)) + ): + raise RelayError("relay_url_not_allowed") + return urlunsplit((parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "")) + + +def _private_key_bytes(key: Ed25519PrivateKey) -> bytes: + return key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption()) + + +def _public_key(key: Ed25519PrivateKey) -> str: + return _b64(key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)) + + +class RelayError(RuntimeError): + pass + + +class RelayStateStore: + """Small mode-0600 local state file; relay access and encryption keys never leave it.""" + + def __init__(self, path: str | Path) -> None: + self.path = Path(path) + + def load(self) -> dict[str, Any]: + if not self.path.exists(): + return {"version": 1, "workspaces": {}} + payload = json.loads(self.path.read_text()) + if payload.get("version") != 1 or not isinstance(payload.get("workspaces"), dict): + raise RelayError("relay_state_version_unsupported") + return payload + + def save(self, payload: dict[str, Any]) -> None: + self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + temporary = self.path.with_suffix(f"{self.path.suffix}.{secrets.token_hex(4)}.tmp") + descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(descriptor, "w") as handle: + handle.write(json.dumps(payload, indent=2, sort_keys=True) + "\n") + handle.flush() + os.fsync(handle.fileno()) + temporary.replace(self.path) + finally: + temporary.unlink(missing_ok=True) + self.path.chmod(0o600) + + def identity(self, actor_name: str = "") -> tuple[str, str, Ed25519PrivateKey]: + state = self.load() + actor_id = str(state.get("actor_id") or f"agt_{uuid.uuid4().hex[:12]}") + name = str(actor_name or state.get("actor_name") or actor_id)[:100] + encoded = str(state.get("signing_private_key") or "") + key = ( + Ed25519PrivateKey.from_private_bytes(_unb64(encoded)) + if encoded + else WorkspaceEnvelopeCodec.generate_signing_key() + ) + state.update( + { + "actor_id": actor_id, + "actor_name": name, + "signing_private_key": _b64(_private_key_bytes(key)), + } + ) + self.save(state) + return actor_id, name, key + + def workspace(self, workspace_id: str) -> dict[str, Any] | None: + return self.load()["workspaces"].get(workspace_id) + + def put_workspace(self, workspace_id: str, value: dict[str, Any]) -> None: + state = self.load() + state["workspaces"][workspace_id] = value + self.save(state) + + def update_cursor(self, workspace_id: str, cursor: int) -> None: + state = self.load() + workspace = state["workspaces"].get(workspace_id) + if workspace is None: + raise RelayError("relay_workspace_not_joined") + workspace["cursor"] = max(int(workspace.get("cursor") or 0), int(cursor)) + self.save(state) + + def joined_ids(self) -> list[str]: + return list(self.load()["workspaces"]) + + +class AttackGraphRelayClient: + def __init__( + self, + state: RelayStateStore, + *, + client: httpx.Client | None = None, + timeout: float = 20.0, + ) -> None: + self.state = state + self.client = client or httpx.Client(timeout=timeout) + + @staticmethod + def _request_error(response: httpx.Response) -> RelayError: + try: + payload = response.json() + except ValueError: + payload = {} + message = str(payload.get("error") or f"relay_http_{response.status_code}") + return RelayError(message) + + def _request( + self, + method: str, + url: str, + *, + access_token: str = "", + json_body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + try: + response = self.client.request( + method, + url, + headers={"authorization": f"Bearer {access_token}"} if access_token else {}, + json=json_body, + ) + except httpx.HTTPError as exc: + raise RelayError("relay_unavailable") from exc + if response.status_code >= 400: + raise self._request_error(response) + try: + payload = response.json() + except ValueError as exc: + raise RelayError("relay_response_invalid") from exc + if not isinstance(payload, dict): + raise RelayError("relay_response_invalid") + return payload + + def host_workspace( + self, + service: AttackGraphService, + engagement_id: str, + *, + relay_url: str = DEFAULT_RELAY_URL, + actor_name: str = "", + bootstrap_token: str = "", + ) -> dict[str, Any]: + if self.state.workspace(engagement_id): + return self.status(engagement_id) + service.memory.get_engagement(engagement_id) + actor_id, name, signing_key = self.state.identity(actor_name) + service.memory.actor_id = actor_id + service.memory.actor_name = name + base = _relay_url(relay_url) + payload = self._request( + "POST", + f"{base}/workspaces", + access_token=bootstrap_token, + json_body={ + "workspace_id": engagement_id, + "title": f"Private workspace {engagement_id[-8:]}", + "actor_id": actor_id, + "actor_name": name, + "signing_public_key": _public_key(signing_key), + }, + ) + workspace_key = generate_workspace_key() + self.state.put_workspace( + engagement_id, + { + "relay_url": base, + "workspace_key": _b64(workspace_key), + "access_token": payload["access_token"], + "role": "owner", + "cursor": 0, + }, + ) + self.push_workspace(service, engagement_id) + return self.status(engagement_id) + + def create_invite( + self, + workspace_id: str, + *, + role: str = "operator", + hours: int = 24, + ) -> dict[str, Any]: + workspace = self._workspace(workspace_id) + secret = secrets.token_bytes(32) + nonce = secrets.token_bytes(12) + key = _unb64(workspace["workspace_key"]) + wrapped = AESGCM(_invite_wrap_key(secret)).encrypt( + nonce, + key, + f"h1dr4-workspace-invite-v1\n{workspace_id}".encode(), + ) + proof = _redeem_proof(secret) + payload = self._request( + "POST", + f"{workspace['relay_url']}/workspaces/{workspace_id}/invites", + access_token=workspace["access_token"], + json_body={ + "role": role, + "hours": hours, + "redeem_proof_hash": _sha256(proof.encode()), + "wrapped_workspace_key": { + "version": 1, + "algorithm": "AES-256-GCM", + "nonce": _b64(nonce), + "ciphertext": _b64(wrapped), + }, + }, + ) + invite_code = INVITE_PREFIX + _json_b64( + { + "version": 1, + "relay_url": workspace["relay_url"], + "invite_id": payload["invite_id"], + "secret": _b64(secret), + } + ) + return { + "workspace_id": workspace_id, + "role": payload["role"], + "expires_at": payload["expires_at"], + "invite_code": invite_code, + "warning": "The invite code grants access once. Send it only to the intended operator.", + } + + def join_workspace( + self, + service: AttackGraphService, + invite_code: str, + *, + actor_name: str, + ) -> dict[str, Any]: + invite = self.decode_invite(invite_code) + base = _relay_url(str(invite["relay_url"])) + invite_id = str(invite["invite_id"]) + secret = _unb64(str(invite["secret"])) + metadata = self._request("GET", f"{base}/invites/{invite_id}") + workspace_id = str(metadata["workspace_id"]) + wrapped = metadata["wrapped_workspace_key"] + if wrapped.get("version") != 1 or wrapped.get("algorithm") != "AES-256-GCM": + raise RelayError("invite_key_envelope_unsupported") + try: + workspace_key = AESGCM(_invite_wrap_key(secret)).decrypt( + _unb64(str(wrapped["nonce"])), + _unb64(str(wrapped["ciphertext"])), + f"h1dr4-workspace-invite-v1\n{workspace_id}".encode(), + ) + except Exception as exc: + raise RelayError("invite_key_decryption_failed") from exc + actor_id, name, signing_key = self.state.identity(actor_name) + service.memory.actor_id = actor_id + service.memory.actor_name = name + joined = self._request( + "POST", + f"{base}/invites/{invite_id}/redeem", + json_body={ + "redeem_proof": _redeem_proof(secret), + "actor_id": actor_id, + "actor_name": name, + "signing_public_key": _public_key(signing_key), + }, + ) + self.state.put_workspace( + workspace_id, + { + "relay_url": base, + "workspace_key": _b64(workspace_key), + "access_token": joined["access_token"], + "role": joined["member"]["role"], + "cursor": 0, + }, + ) + pulled = self.pull_workspace(service, workspace_id) + return {**self.status(workspace_id), "pulled_events": pulled} + + def members(self, workspace_id: str) -> list[dict[str, Any]]: + workspace = self._workspace(workspace_id) + payload = self._request( + "GET", + f"{workspace['relay_url']}/workspaces/{workspace_id}/members", + access_token=workspace["access_token"], + ) + return list(payload.get("members") or []) + + def revoke_member(self, workspace_id: str, member_id: str) -> dict[str, Any]: + workspace = self._workspace(workspace_id) + if workspace.get("role") != "owner": + raise RelayError("relay_owner_required") + payload = self._request( + "DELETE", + f"{workspace['relay_url']}/workspaces/{workspace_id}/members/{member_id}", + access_token=workspace["access_token"], + ) + return dict(payload["member"]) + + @staticmethod + def decode_invite(invite_code: str) -> dict[str, Any]: + if not invite_code.startswith(INVITE_PREFIX): + raise RelayError("invite_code_invalid") + try: + payload = json.loads(_unb64(invite_code.removeprefix(INVITE_PREFIX))) + except (ValueError, UnicodeDecodeError) as exc: + raise RelayError("invite_code_invalid") from exc + if payload.get("version") != 1 or not all( + payload.get(key) for key in ("relay_url", "invite_id", "secret") + ): + raise RelayError("invite_code_invalid") + return payload + + def pull_workspace(self, service: AttackGraphService, workspace_id: str) -> int: + workspace = self._workspace(workspace_id) + codec = WorkspaceEnvelopeCodec(_unb64(workspace["workspace_key"])) + cursor = int(workspace.get("cursor") or 0) + pulled = 0 + while True: + previous_cursor = cursor + payload = self._request( + "GET", + f"{workspace['relay_url']}/workspaces/{workspace_id}/events?after={cursor}&limit=25", + access_token=workspace["access_token"], + ) + events = payload.get("events") or [] + if not isinstance(events, list): + raise RelayError("relay_events_invalid") + if not events: + break + for row in events: + try: + envelope = WorkspaceEnvelope( + **{ + key: row[key] + for key in ( + "version", + "workspace_id", + "event_id", + "actor_id", + "created_at", + "nonce", + "ciphertext", + "ciphertext_hash", + "signing_public_key", + "signature", + ) + } + ) + if envelope.workspace_id != workspace_id: + raise ValueError("relay_event_workspace_mismatch") + opened = codec.open(envelope) + if opened.get("kind") != "workspace.snapshot": + raise ValueError("relay_event_kind_unsupported") + service.memory.import_workspace(opened["snapshot"]) + cursor = max(cursor, int(row["cursor"])) + pulled += 1 + except Exception as exc: + raise RelayError("relay_event_invalid") from exc + if cursor <= previous_cursor: + raise RelayError("relay_cursor_not_advanced") + self.state.update_cursor(workspace_id, cursor) + return pulled + + def pull_all(self, service: AttackGraphService) -> int: + return sum( + self.pull_workspace(service, workspace_id) for workspace_id in self.state.joined_ids() + ) + + def push_workspace(self, service: AttackGraphService, workspace_id: str) -> dict[str, Any]: + workspace = self._workspace(workspace_id) + if workspace.get("role") == "observer": + raise RelayError("relay_observer_cannot_write") + state = self.state.load() + signing_key = Ed25519PrivateKey.from_private_bytes( + _unb64(str(state["signing_private_key"])) + ) + codec = WorkspaceEnvelopeCodec(_unb64(workspace["workspace_key"])) + envelope = codec.seal( + workspace_id=workspace_id, + actor_id=str(state["actor_id"]), + payload={ + "kind": "workspace.snapshot", + "snapshot": service.memory.export_workspace(workspace_id), + }, + signing_key=signing_key, + ) + return self._request( + "POST", + f"{workspace['relay_url']}/workspaces/{workspace_id}/events", + access_token=workspace["access_token"], + json_body={"envelope": envelope.to_dict()}, + ) + + def status(self, workspace_id: str = "") -> dict[str, Any]: + state = self.state.load() + ids = [workspace_id] if workspace_id else list(state["workspaces"]) + return { + "actor_id": state.get("actor_id"), + "actor_name": state.get("actor_name"), + "workspaces": [ + { + "workspace_id": item, + "relay_url": state["workspaces"][item]["relay_url"], + "role": state["workspaces"][item]["role"], + "cursor": int(state["workspaces"][item].get("cursor") or 0), + } + for item in ids + if item in state["workspaces"] + ], + } + + def _workspace(self, workspace_id: str) -> dict[str, Any]: + workspace = self.state.workspace(workspace_id) + if not workspace: + raise RelayError("relay_workspace_not_joined") + return workspace + + +class RelayCoordinator: + def __init__(self, client: AttackGraphRelayClient) -> None: + self.client = client + self.last_error = "" + + @classmethod + def from_env(cls) -> RelayCoordinator: + path = os.getenv("ATTACKGRAPH_RELAY_STATE_PATH", ".attackgraph/relay.json") + return cls(AttackGraphRelayClient(RelayStateStore(path))) + + def pull(self, service: AttackGraphService, engagement_id: str = "") -> int: + if engagement_id: + return ( + self.client.pull_workspace(service, engagement_id) + if self.client.state.workspace(engagement_id) + else 0 + ) + return self.client.pull_all(service) + + def push(self, service: AttackGraphService, engagement_id: str) -> dict[str, Any] | None: + if not self.client.state.workspace(engagement_id): + return None + self.pull(service, engagement_id) + return self.client.push_workspace(service, engagement_id) + + def pull_best_effort(self, service: AttackGraphService, engagement_id: str = "") -> int: + try: + result = self.pull(service, engagement_id) + self.last_error = "" + return result + except RelayError as exc: + self.last_error = str(exc) + return 0 + + def push_best_effort( + self, service: AttackGraphService, engagement_id: str + ) -> dict[str, Any] | None: + try: + result = self.push(service, engagement_id) + self.last_error = "" + return result + except RelayError as exc: + self.last_error = str(exc) + return None diff --git a/src/h1dr4_attackgraph/relay_cli.py b/src/h1dr4_attackgraph/relay_cli.py new file mode 100644 index 0000000..c37ca1d --- /dev/null +++ b/src/h1dr4_attackgraph/relay_cli.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +from typing import Any + +from .relay import ( + DEFAULT_RELAY_URL, + AttackGraphRelayClient, + RelayError, + RelayStateStore, +) +from .service import AttackGraphService + + +def _state() -> RelayStateStore: + return RelayStateStore( + Path(os.getenv("ATTACKGRAPH_RELAY_STATE_PATH", ".attackgraph/relay.json")).resolve() + ) + + +def _service(state: RelayStateStore) -> AttackGraphService: + relay_state = state.load() + return AttackGraphService( + db_path=Path(os.getenv("ATTACKGRAPH_DB_PATH", ".attackgraph/sibyl.db")).resolve(), + operator_id=os.getenv("ATTACKGRAPH_OPERATOR_ID", "local-operator"), + identity=None, + actor_id=str(relay_state.get("actor_id") or ""), + actor_name=str(relay_state.get("actor_name") or "local-operator"), + ) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="attackgraph", + description="Host, join, and synchronize H1DR4 AttackGraph private workspaces.", + ) + commands = parser.add_subparsers(dest="command", required=True) + + host = commands.add_parser("host", help="Host an existing local engagement on the relay.") + host.add_argument("engagement_id") + host.add_argument("--relay", default=os.getenv("ATTACKGRAPH_RELAY_URL", DEFAULT_RELAY_URL)) + host.add_argument("--name", default=os.getenv("ATTACKGRAPH_RELAY_ACTOR_NAME", "")) + + invite = commands.add_parser("invite", help="Create a one-use encrypted join code.") + invite.add_argument("engagement_id") + invite.add_argument("--role", choices=("operator", "observer"), default="operator") + invite.add_argument("--hours", type=int, default=24) + + join = commands.add_parser("join", help="Join a private workspace from another machine.") + join.add_argument("invite_code") + join.add_argument("--name", required=True) + + members = commands.add_parser("members", help="List members of a private workspace.") + members.add_argument("engagement_id") + + revoke = commands.add_parser("revoke", help="Revoke one member as the workspace owner.") + revoke.add_argument("engagement_id") + revoke.add_argument("member_id") + + sync = commands.add_parser("sync", help="Synchronize one or all joined workspaces.") + sync.add_argument("engagement_id", nargs="?") + + commands.add_parser("status", help="Show joined workspaces without revealing secrets.") + return parser + + +def _run(args: argparse.Namespace) -> dict[str, Any]: + state = _state() + client = AttackGraphRelayClient(state) + if args.command == "status": + return client.status() + service = _service(state) + if args.command == "host": + return client.host_workspace( + service, + args.engagement_id, + relay_url=args.relay, + actor_name=args.name, + bootstrap_token=os.getenv("ATTACKGRAPH_RELAY_BOOTSTRAP_TOKEN", ""), + ) + if args.command == "invite": + return client.create_invite(args.engagement_id, role=args.role, hours=args.hours) + if args.command == "join": + return client.join_workspace(service, args.invite_code, actor_name=args.name) + if args.command == "members": + return { + "workspace_id": args.engagement_id, + "members": client.members(args.engagement_id), + } + if args.command == "revoke": + return { + "workspace_id": args.engagement_id, + "member": client.revoke_member(args.engagement_id, args.member_id), + } + if args.command == "sync": + if args.engagement_id: + pulled = client.pull_workspace(service, args.engagement_id) + pushed = client.push_workspace(service, args.engagement_id) + else: + pulled = client.pull_all(service) + pushed = [client.push_workspace(service, item) for item in state.joined_ids()] + return {"pulled_events": pulled, "published": bool(pushed)} + raise RelayError("relay_command_unsupported") + + +def main() -> None: + try: + result = _run(_parser().parse_args()) + except (RelayError, ValueError) as exc: + raise SystemExit(f"attackgraph: {exc}") from exc + print(json.dumps(result, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/src/h1dr4_attackgraph/server.py b/src/h1dr4_attackgraph/server.py index 783ff13..1f464b8 100644 --- a/src/h1dr4_attackgraph/server.py +++ b/src/h1dr4_attackgraph/server.py @@ -7,6 +7,7 @@ from mcp.server.fastmcp import FastMCP from .identity import IdentityStore +from .relay import DEFAULT_RELAY_URL, RelayCoordinator from .service import AttackGraphService, ConfigurationError mcp = FastMCP( @@ -21,6 +22,11 @@ ) +@lru_cache(maxsize=1) +def get_relay() -> RelayCoordinator: + return RelayCoordinator.from_env() + + @lru_cache(maxsize=1) def get_service() -> AttackGraphService: identity = IdentityStore(os.getenv("ATTACKGRAPH_CONTROL_DB_PATH", ".attackgraph/control.db")) @@ -28,16 +34,31 @@ def get_service() -> AttackGraphService: agent = identity.authenticate_agent(token) if token else None if token and not agent: raise ConfigurationError("ATTACKGRAPH_AGENT_TOKEN is invalid or revoked") + relay_state = get_relay().client.state.load() return AttackGraphService( db_path=os.getenv("ATTACKGRAPH_DB_PATH", ".attackgraph/sibyl.db"), operator_id=os.getenv("ATTACKGRAPH_OPERATOR_ID", "local-operator"), identity=identity, principal_type="agent" if agent else "human", principal_id=str(agent["agent_id"]) if agent else "", - actor_name=str(agent["name"]) if agent else "local-operator", + actor_id=str(relay_state.get("actor_id") or ""), + actor_name=str( + relay_state.get("actor_name") or (agent["name"] if agent else "local-operator") + ), ) +def _read_service(engagement_id: str = "") -> AttackGraphService: + service = get_service() + get_relay().pull_best_effort(service, engagement_id) + return service + + +def _after_write(service: AttackGraphService, engagement_id: str, result: Any) -> Any: + get_relay().push_best_effort(service, engagement_id) + return result + + @mcp.tool() def attackgraph_open_engagement( title: str, @@ -63,19 +84,19 @@ def attackgraph_open_engagement( @mcp.tool() def attackgraph_list_engagements() -> list[dict[str, Any]]: """List this operator's isolated Sibyl engagements.""" - return get_service().list_engagements() + return _read_service().list_engagements() @mcp.tool() def attackgraph_get_context(engagement_id: str, event_limit: int = 25) -> dict[str, Any]: """Return the complete hot graph plus recent append-only Sibyl events.""" - return get_service().context(engagement_id, event_limit=event_limit) + return _read_service(engagement_id).context(engagement_id, event_limit=event_limit) @mcp.tool() def attackgraph_get_brief(engagement_id: str) -> dict[str, Any]: """Return a compact handoff for the connected agent to reason over.""" - return get_service().brief(engagement_id) + return _read_service(engagement_id).brief(engagement_id) @mcp.tool() @@ -106,7 +127,8 @@ def attackgraph_report_event( This is an agent assertion and cannot self-declare verified. Reuse the same idempotency_key when a later executor attestation should promote it. """ - return get_service().report_telemetry( + service = _read_service(engagement_id) + result = service.report_telemetry( engagement_id, event_type=event_type, summary=summary, @@ -122,6 +144,7 @@ def attackgraph_report_event( attributes=attributes, idempotency_key=idempotency_key, ) + return _after_write(service, engagement_id, result) @mcp.tool() @@ -154,7 +177,8 @@ def attackgraph_ingest_h3retik_event( high-impact posture. This adapter-only tool requires the server-configured H3RETIK attestation token; do not expose that credential to agent workers. """ - return get_service().ingest_h3retik_telemetry( + service = _read_service(engagement_id) + result = service.ingest_h3retik_telemetry( engagement_id, event_type=event_type, summary=summary, @@ -176,6 +200,7 @@ def attackgraph_ingest_h3retik_event( idempotency_key=idempotency_key, attestation_token=attestation_token, ) + return _after_write(service, engagement_id, result) @mcp.tool() @@ -193,7 +218,8 @@ def attackgraph_record_observation( here remain unverified; only correlated executor telemetry can promote a high-impact dashboard state. """ - return get_service().record_observation( + service = _read_service(engagement_id) + result = service.record_observation( engagement_id, statement=statement, source=source, @@ -201,6 +227,7 @@ def attackgraph_record_observation( kind=kind, evidence=evidence, ) + return _after_write(service, engagement_id, result) @mcp.tool() @@ -208,7 +235,9 @@ def attackgraph_record_hypothesis( engagement_id: str, statement: str, status: str = "open" ) -> dict[str, Any]: """Record an open, confirmed, or rejected attack hypothesis.""" - return get_service().record_hypothesis(engagement_id, statement=statement, status=status) + service = _read_service(engagement_id) + result = service.record_hypothesis(engagement_id, statement=statement, status=status) + return _after_write(service, engagement_id, result) @mcp.tool() @@ -220,13 +249,15 @@ def attackgraph_record_attempt( evidence: dict[str, Any] | None = None, ) -> dict[str, Any]: """Record an attempted path so future sessions do not blindly repeat it.""" - return get_service().record_attempt( + service = _read_service(engagement_id) + result = service.record_attempt( engagement_id, approach=approach, outcome=outcome, exhausted=exhausted, evidence=evidence, ) + return _after_write(service, engagement_id, result) @mcp.tool() @@ -242,7 +273,8 @@ def attackgraph_request_action( h3retik_session_id: str = "", ) -> dict[str, Any]: """Policy-check and record an action plan. This does not execute the command.""" - return get_service().request_action( + service = _read_service(engagement_id) + result = service.request_action( engagement_id, target=target, lane=lane, @@ -253,6 +285,7 @@ def attackgraph_request_action( budget_usdc=budget_usdc, h3retik_session_id=h3retik_session_id, ) + return _after_write(service, engagement_id, result) @mcp.tool() @@ -324,9 +357,11 @@ def attackgraph_execute_approved_h3retik_job( engagement_id: str, action_id: str, approval_code: str ) -> dict[str, Any]: """Execute one already-scoped action in an existing session after human approval.""" - return get_service().execute_approved_h3retik_job( + service = _read_service(engagement_id) + result = service.execute_approved_h3retik_job( engagement_id, action_id=action_id, approval_code=approval_code ) + return _after_write(service, engagement_id, result) @mcp.tool() @@ -338,13 +373,15 @@ def attackgraph_ingest_h3retik_result( status: str = "completed", ) -> dict[str, Any]: """Import externally run H3RETIK output as sanitized Sibyl evidence.""" - return get_service().ingest_h3retik_result( + service = _read_service(engagement_id) + recorded = service.ingest_h3retik_result( engagement_id, action_id=action_id, job_id=job_id, result=result, status=status, ) + return _after_write(service, engagement_id, recorded) @mcp.tool() @@ -352,7 +389,92 @@ def attackgraph_create_regression( engagement_id: str, name: str, check: str, expected: str ) -> dict[str, Any]: """Turn a confirmed finding into a durable regression check.""" - return get_service().create_regression(engagement_id, name=name, check=check, expected=expected) + service = _read_service(engagement_id) + result = service.create_regression(engagement_id, name=name, check=check, expected=expected) + return _after_write(service, engagement_id, result) + + +@mcp.tool() +def attackgraph_host_private_workspace( + engagement_id: str, + relay_url: str = DEFAULT_RELAY_URL, + actor_name: str = "", +) -> dict[str, Any]: + """Host an existing engagement through the H1DR4 ciphertext-only private relay.""" + return get_relay().client.host_workspace( + get_service(), + engagement_id, + relay_url=relay_url, + actor_name=actor_name, + bootstrap_token=os.getenv("ATTACKGRAPH_RELAY_BOOTSTRAP_TOKEN", ""), + ) + + +@mcp.tool() +def attackgraph_create_private_invite( + engagement_id: str, + role: str = "operator", + hours: int = 24, +) -> dict[str, Any]: + """Create a one-use encrypted workspace invite. Treat the returned code as a secret.""" + return get_relay().client.create_invite(engagement_id, role=role, hours=hours) + + +@mcp.tool() +def attackgraph_join_private_workspace( + invite_code: str, + actor_name: str, +) -> dict[str, Any]: + """Join a private workspace with a one-use secret invite and hydrate local Sibyl.""" + return get_relay().client.join_workspace( + get_service(), + invite_code, + actor_name=actor_name, + ) + + +@mcp.tool() +def attackgraph_private_workspace_members(engagement_id: str) -> dict[str, Any]: + """List the authorized members and revocation state for a private workspace.""" + return { + "workspace_id": engagement_id, + "members": get_relay().client.members(engagement_id), + } + + +@mcp.tool() +def attackgraph_revoke_private_workspace_member( + engagement_id: str, + member_id: str, +) -> dict[str, Any]: + """Revoke a private relay member. Only the workspace owner may do this.""" + return { + "workspace_id": engagement_id, + "member": get_relay().client.revoke_member(engagement_id, member_id), + } + + +@mcp.tool() +def attackgraph_sync_private_workspace(engagement_id: str) -> dict[str, Any]: + """Pull encrypted remote events into local Sibyl and publish the merged snapshot.""" + service = get_service() + pulled = get_relay().pull(service, engagement_id) + pushed = get_relay().push(service, engagement_id) + return { + "engagement_id": engagement_id, + "pulled_events": pulled, + "published": bool(pushed), + } + + +@mcp.tool() +def attackgraph_private_workspace_status() -> dict[str, Any]: + """List locally joined private workspaces without exposing relay credentials or keys.""" + coordinator = get_relay() + return { + **coordinator.client.status(), + "last_sync_error": coordinator.last_error or None, + } @mcp.tool() diff --git a/src/h1dr4_attackgraph/service.py b/src/h1dr4_attackgraph/service.py index f5d2162..55b4434 100644 --- a/src/h1dr4_attackgraph/service.py +++ b/src/h1dr4_attackgraph/service.py @@ -30,6 +30,7 @@ def __init__( identity: IdentityStore | None = None, principal_type: str = "human", principal_id: str = "", + actor_id: str = "", actor_name: str = "", ) -> None: self.identity = identity @@ -38,8 +39,8 @@ def __init__( self.memory = SibylAttackMemory( db_path, operator_id, - actor_id=principal_id or operator_id, - actor_name=actor_name or principal_id or operator_id, + actor_id=actor_id or principal_id or operator_id, + actor_name=actor_name or actor_id or principal_id or operator_id, actor_type=principal_type, ) self.h1dr4 = h1dr4 or H1dr4Client() diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 00d1294..cf63733 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -16,5 +16,12 @@ async def test_mcp_surface_is_model_agnostic_and_contains_execution_loop(): assert "attackgraph_create_h3retik_extension_receipt" in names assert "attackgraph_sync_h3retik_receipt" in names assert "attackgraph_discover_h1dr4_tools" in names + assert "attackgraph_host_private_workspace" in names + assert "attackgraph_create_private_invite" in names + assert "attackgraph_join_private_workspace" in names + assert "attackgraph_private_workspace_members" in names + assert "attackgraph_revoke_private_workspace_member" in names + assert "attackgraph_sync_private_workspace" in names + assert "attackgraph_private_workspace_status" in names assert not any("qwen" in name or "recommend_next_move" in name for name in names) - assert len(names) == 22 + assert len(names) == 29 diff --git a/tests/test_relay.py b/tests/test_relay.py new file mode 100644 index 0000000..0261a26 --- /dev/null +++ b/tests/test_relay.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import hashlib +import json +import stat +from pathlib import Path +from urllib.parse import parse_qs + +import httpx +import pytest + +from h1dr4_attackgraph.relay import AttackGraphRelayClient, RelayError, RelayStateStore +from h1dr4_attackgraph.service import AttackGraphService + + +class RelayFixture: + def __init__(self) -> None: + self.invites: dict[str, dict] = {} + self.events: list[dict] = [] + self.tokens = {"bootstrap": "bootstrap"} + self.members: dict[str, dict] = {} + + def handler(self, request: httpx.Request) -> httpx.Response: + path = request.url.path.removeprefix("/v1/attackgraph") + body = json.loads(request.content or b"{}") + if request.method == "POST" and path == "/workspaces": + self.tokens["owner-token"] = "owner" + self.members["member-owner"] = { + "member_id": "member-owner", + "actor_id": body["actor_id"], + "role": "owner", + "status": "active", + } + return self.response( + request, + 201, + { + "workspace": {"workspace_id": body["workspace_id"]}, + "member": {"role": "owner"}, + "access_token": "owner-token", + }, + ) + if request.method == "POST" and path.endswith("/invites"): + invite_id = f"agi_{len(self.invites) + 1}" + self.invites[invite_id] = {**body, "workspace_id": path.split("/")[2]} + return self.response( + request, + 201, + { + "invite_id": invite_id, + "workspace_id": self.invites[invite_id]["workspace_id"], + "role": body["role"], + "expires_at": "2099-01-01T00:00:00Z", + }, + ) + if request.method == "GET" and path.startswith("/invites/"): + invite_id = path.rsplit("/", 1)[-1] + invite = self.invites[invite_id] + return self.response( + request, + 200, + { + "invite_id": invite_id, + "workspace_id": invite["workspace_id"], + "role": invite["role"], + "expires_at": "2099-01-01T00:00:00Z", + "wrapped_workspace_key": invite["wrapped_workspace_key"], + }, + ) + if request.method == "POST" and path.endswith("/redeem"): + invite_id = path.split("/")[2] + invite = self.invites[invite_id] + proof_hash = hashlib.sha256(body["redeem_proof"].encode()).hexdigest() + assert proof_hash == invite["redeem_proof_hash"] + self.tokens["friend-token"] = "operator" + self.members["member-friend"] = { + "member_id": "member-friend", + "actor_id": body["actor_id"], + "role": invite["role"], + "status": "active", + } + return self.response( + request, + 201, + { + "workspace_id": invite["workspace_id"], + "member": {"role": invite["role"]}, + "access_token": "friend-token", + "wrapped_workspace_key": invite["wrapped_workspace_key"], + }, + ) + if request.method == "POST" and path.endswith("/events"): + row = {**body["envelope"], "cursor": len(self.events) + 1} + self.events.append(row) + return self.response( + request, + 201, + {"event_id": row["event_id"], "cursor": row["cursor"], "created": True}, + ) + if request.method == "GET" and path.endswith("/events"): + after = int(parse_qs(request.url.query.decode()).get("after", ["0"])[0]) + rows = [event for event in self.events if event["cursor"] > after] + return self.response( + request, + 200, + { + "workspace_id": path.split("/")[2], + "after": after, + "next_cursor": rows[-1]["cursor"] if rows else after, + "events": rows, + }, + ) + if request.method == "GET" and path.endswith("/members"): + return self.response( + request, + 200, + { + "workspace_id": path.split("/")[2], + "members": list(self.members.values()), + }, + ) + if request.method == "DELETE" and "/members/" in path: + member_id = path.rsplit("/", 1)[-1] + self.members[member_id]["status"] = "revoked" + return self.response( + request, + 200, + {"workspace_id": path.split("/")[2], "member": self.members[member_id]}, + ) + raise AssertionError(f"unexpected relay request: {request.method} {path}") + + @staticmethod + def response(request: httpx.Request, status: int, body: dict) -> httpx.Response: + return httpx.Response(status, json=body, request=request) + + +def service(path: Path, operator: str) -> AttackGraphService: + return AttackGraphService(db_path=path, operator_id=operator, identity=None) + + +def test_two_local_sibyls_exchange_encrypted_workspace_snapshots( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("ATTACKGRAPH_RELAY_ALLOWED_HOSTS", "relay.test") + relay = RelayFixture() + transport = httpx.MockTransport(relay.handler) + owner_service = service(tmp_path / "owner.db", "owner") + engagement = owner_service.open_engagement( + title="Owned Juice Shop", + target="http://juice-shop.internal", + mode="autonomous_lab", + scope="Owned OWASP training fixture", + target_allowlist=["http://juice-shop.internal"], + ) + owner = AttackGraphRelayClient( + RelayStateStore(tmp_path / "owner-relay.json"), + client=httpx.Client(transport=transport), + ) + hosted = owner.host_workspace( + owner_service, + engagement["engagement_id"], + relay_url="https://relay.test/v1/attackgraph", + actor_name="WEB-01", + bootstrap_token="bootstrap", + ) + assert hosted["workspaces"][0]["role"] == "owner" + assert "snapshot" not in json.dumps(relay.events) + + invite = owner.create_invite(engagement["engagement_id"], role="operator") + friend_service = service(tmp_path / "friend.db", "friend") + friend = AttackGraphRelayClient( + RelayStateStore(tmp_path / "friend-relay.json"), + client=httpx.Client(transport=transport), + ) + joined = friend.join_workspace(friend_service, invite["invite_code"], actor_name="AUTH-02") + assert joined["pulled_events"] == 1 + brief = friend_service.brief(engagement["engagement_id"]) + assert brief["engagement"]["title"] == "Owned Juice Shop" + + friend_service.record_attempt( + engagement["engagement_id"], + approach="Default credentials", + outcome="failed", + exhausted=True, + evidence={"status": 401}, + ) + friend.push_workspace(friend_service, engagement["engagement_id"]) + pulled = owner.pull_workspace(owner_service, engagement["engagement_id"]) + assert pulled == 2 + owner_brief = owner_service.brief(engagement["engagement_id"]) + assert owner_brief["exhausted_paths"][0]["approach"] == "Default credentials" + + members = owner.members(engagement["engagement_id"]) + friend_member = next(item for item in members if item["actor_id"] == joined["actor_id"]) + revoked = owner.revoke_member(engagement["engagement_id"], friend_member["member_id"]) + assert revoked["status"] == "revoked" + + relay_bytes = json.dumps(relay.events) + assert "Default credentials" not in relay_bytes + assert "juice-shop.internal" not in relay_bytes + + +def test_relay_state_is_created_private_and_untrusted_host_is_rejected( + tmp_path: Path, +) -> None: + state = RelayStateStore(tmp_path / "state" / "relay.json") + state.identity("WEB-01") + assert stat.S_IMODE(state.path.stat().st_mode) == 0o600 + + service = AttackGraphService(db_path=tmp_path / "owner.db", operator_id="owner", identity=None) + engagement = service.open_engagement( + title="Confidential Target", + target="https://example.test", + mode="local_lab", + scope="Owned fixture", + target_allowlist=["https://example.test"], + ) + client = AttackGraphRelayClient( + state, + client=httpx.Client( + transport=httpx.MockTransport( + lambda request: pytest.fail(f"unexpected request: {request.url}") + ) + ), + ) + with pytest.raises(RelayError, match="relay_url_not_allowed"): + client.host_workspace( + service, + engagement["engagement_id"], + relay_url="https://attacker.example/v1/attackgraph", + bootstrap_token="must-not-leak", + ) From d50f14f3c5c83435158a00b9cf09dfeab089b5ed Mon Sep 17 00:00:00 2001 From: NATIVE Date: Fri, 11 Sep 2026 02:13:22 +0200 Subject: [PATCH 2/3] docs: explain hosted multiplayer workflow --- docs/PRIVATE_RELAY.md | 66 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/docs/PRIVATE_RELAY.md b/docs/PRIVATE_RELAY.md index f9b625a..0e059ec 100644 --- a/docs/PRIVATE_RELAY.md +++ b/docs/PRIVATE_RELAY.md @@ -57,6 +57,63 @@ enrollment flow directly. Never paste an invite code into a recorded prompt or commit it. Deliver it to the intended operator over an authenticated private channel. +## Hosted H1DR4 relay + +The production client endpoint is: + +```text +https://h1dr4.dev/api/v1/attackgraph +``` + +Set the same local paths in the shell and in the MCP configuration so the CLI +and the connected agent use one Sibyl and one relay identity: + +```bash +export ATTACKGRAPH_DB_PATH=/absolute/path/attackgraph/sibyl.db +export ATTACKGRAPH_RELAY_STATE_PATH=/absolute/path/attackgraph/relay.json +export ATTACKGRAPH_RELAY_URL=https://h1dr4.dev/api/v1/attackgraph +``` + +Creating a new hosted workspace is currently a private-beta operation. The +owner needs a short-lived `ATTACKGRAPH_RELAY_BOOTSTRAP_TOKEN` issued by H1DR4 +for the `host` call only. The bootstrap token is not a workspace credential and +must not be sent to collaborators. A collaborator needs only the one-use invite +code; redemption returns an independent, revocable membership credential. + +Owner: + +```bash +uv run attackgraph host eng-example --name WEB-01 +uv run attackgraph invite eng-example --role operator --hours 24 +``` + +Collaborator, on another machine: + +```bash +export ATTACKGRAPH_DB_PATH=/absolute/path/friend/sibyl.db +export ATTACKGRAPH_RELAY_STATE_PATH=/absolute/path/friend/relay.json +uv run attackgraph join 'h1dr4-ag1:REDACTED' --name AUTH-02 +uv run attackgraph sync eng-example +``` + +The equivalent MCP tools are +`attackgraph_host_private_workspace`, `attackgraph_create_private_invite`, +`attackgraph_join_private_workspace`, +`attackgraph_sync_private_workspace`, +`attackgraph_private_workspace_members`, and +`attackgraph_revoke_private_workspace_member`. Normal AttackGraph reads pull +before returning a brief and normal writes publish after the local Sibyl +commit, so explicit `sync` is mainly a catch-up and diagnostic control. + +## What multiplayer does not share + +- H3RETIK wallet, session bearer, and executor-attestation secrets remain local. +- Dashboard passkeys and MCP agent tokens remain device-local identities. +- The relay never receives the SQLite database, workspace key, invite secret, + target, finding text, loot, command output, or report body in plaintext. +- A workspace can reference several H3RETIK session IDs, but the machines do + not need to be co-located with either operator. + ## Revocation Revocation immediately blocks future relay reads and writes for the member's @@ -81,3 +138,12 @@ uv run python scripts/smoke_private_relay.py The smoke creates two isolated Sibyl databases, joins the second operator, publishes an exhausted attack path, recalls it from the first database, and asserts that neither the target nor the path appears in relay-visible JSON. + +Run the same receipt against a deployed relay by supplying its URL and a +bootstrap token through the environment: + +```bash +ATTACKGRAPH_RELAY_BOOTSTRAP_TOKEN=REDACTED \ +uv run python scripts/smoke_private_relay.py \ + --relay https://h1dr4.dev/api/v1/attackgraph +``` From 227dbf5632b90e6ef4a9751929d8f6516ed668f1 Mon Sep 17 00:00:00 2001 From: NATIVE Date: Fri, 11 Sep 2026 02:43:42 +0200 Subject: [PATCH 3/3] feat: add passkey workspace authorization --- README.md | 6 ++ docs/PRIVATE_RELAY.md | 33 +++++--- src/h1dr4_attackgraph/relay.py | 119 ++++++++++++++++++++++++++++- src/h1dr4_attackgraph/relay_cli.py | 43 ++++++++++- src/h1dr4_attackgraph/server.py | 20 ++++- tests/test_relay.py | 60 +++++++++++++++ 6 files changed, 264 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index c2b8981..e6d137e 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,12 @@ uv run attackgraph host eng-example --name WEB-01 uv run attackgraph invite eng-example --role operator --hours 24 ``` +`host` opens `h1dr4.dev` for a passkey approval and then continues +automatically. It needs no shared API secret, wallet, payment, or hosted Sibyl. +Use `--no-browser` on a headless machine and open the printed URL on another +device. See [Private workspace relay](docs/PRIVATE_RELAY.md) for the protocol, +trust boundary, MCP flow, revocation, and local development smoke. + On another machine, using the same absolute database and relay-state paths as its MCP configuration: diff --git a/docs/PRIVATE_RELAY.md b/docs/PRIVATE_RELAY.md index 0e059ec..fc4af59 100644 --- a/docs/PRIVATE_RELAY.md +++ b/docs/PRIVATE_RELAY.md @@ -74,11 +74,17 @@ export ATTACKGRAPH_RELAY_STATE_PATH=/absolute/path/attackgraph/relay.json export ATTACKGRAPH_RELAY_URL=https://h1dr4.dev/api/v1/attackgraph ``` -Creating a new hosted workspace is currently a private-beta operation. The -owner needs a short-lived `ATTACKGRAPH_RELAY_BOOTSTRAP_TOKEN` issued by H1DR4 -for the `host` call only. The bootstrap token is not a workspace credential and -must not be sent to collaborators. A collaborator needs only the one-use invite -code; redemption returns an independent, revocable membership credential. +Creating a hosted workspace is self-service and does not require an API key, +wallet, payment, or account password. `attackgraph host` requests a short-lived +device authorization, opens the approval page on `h1dr4.dev`, and waits for a +passkey confirmation. H1DR4 then returns a five-minute, one-use creation grant +bound to that client's Ed25519 key. The grant cannot create a workspace for a +different operator and is consumed by the first successful host request. + +The passkey is a stable human-presence credential, not a legal identity or an +authorization to test a target. Engagement scope and permission remain the +operator's responsibility. The server stores the passkey public credential; +the private passkey remains in the device, password manager, or security key. Owner: @@ -87,6 +93,12 @@ uv run attackgraph host eng-example --name WEB-01 uv run attackgraph invite eng-example --role operator --hours 24 ``` +On a headless machine, print the same approval URL instead of opening it: + +```bash +uv run attackgraph host eng-example --name WEB-01 --no-browser +``` + Collaborator, on another machine: ```bash @@ -96,8 +108,10 @@ uv run attackgraph join 'h1dr4-ag1:REDACTED' --name AUTH-02 uv run attackgraph sync eng-example ``` -The equivalent MCP tools are -`attackgraph_host_private_workspace`, `attackgraph_create_private_invite`, +The equivalent MCP tool `attackgraph_host_private_workspace` is deliberately +non-blocking: its first call returns `status: approval_required` plus the +passkey URL; call it again after approval to create the workspace. The other +tools are `attackgraph_create_private_invite`, `attackgraph_join_private_workspace`, `attackgraph_sync_private_workspace`, `attackgraph_private_workspace_members`, and @@ -139,8 +153,9 @@ The smoke creates two isolated Sibyl databases, joins the second operator, publishes an exhausted attack path, recalls it from the first database, and asserts that neither the target nor the path appears in relay-visible JSON. -Run the same receipt against a deployed relay by supplying its URL and a -bootstrap token through the environment: +Run the same receipt against a deployed relay with an administrator-issued +bootstrap token. This bypass is only for automated service smoke tests; normal +operators use the passkey flow above: ```bash ATTACKGRAPH_RELAY_BOOTSTRAP_TOKEN=REDACTED \ diff --git a/src/h1dr4_attackgraph/relay.py b/src/h1dr4_attackgraph/relay.py index 79505d5..acdb27b 100644 --- a/src/h1dr4_attackgraph/relay.py +++ b/src/h1dr4_attackgraph/relay.py @@ -103,10 +103,13 @@ def __init__(self, path: str | Path) -> None: def load(self) -> dict[str, Any]: if not self.path.exists(): - return {"version": 1, "workspaces": {}} + return {"version": 1, "workspaces": {}, "pending_authorizations": {}} payload = json.loads(self.path.read_text()) if payload.get("version") != 1 or not isinstance(payload.get("workspaces"), dict): raise RelayError("relay_state_version_unsupported") + payload.setdefault("pending_authorizations", {}) + if not isinstance(payload["pending_authorizations"], dict): + raise RelayError("relay_state_version_unsupported") return payload def save(self, payload: dict[str, Any]) -> None: @@ -162,6 +165,19 @@ def update_cursor(self, workspace_id: str, cursor: int) -> None: def joined_ids(self) -> list[str]: return list(self.load()["workspaces"]) + def pending_authorization(self, workspace_id: str) -> dict[str, Any] | None: + return self.load()["pending_authorizations"].get(workspace_id) + + def put_pending_authorization(self, workspace_id: str, value: dict[str, Any]) -> None: + state = self.load() + state["pending_authorizations"][workspace_id] = value + self.save(state) + + def clear_pending_authorization(self, workspace_id: str) -> None: + state = self.load() + state["pending_authorizations"].pop(workspace_id, None) + self.save(state) + class AttackGraphRelayClient: def __init__( @@ -218,6 +234,7 @@ def host_workspace( relay_url: str = DEFAULT_RELAY_URL, actor_name: str = "", bootstrap_token: str = "", + creation_token: str = "", ) -> dict[str, Any]: if self.state.workspace(engagement_id): return self.status(engagement_id) @@ -229,7 +246,7 @@ def host_workspace( payload = self._request( "POST", f"{base}/workspaces", - access_token=bootstrap_token, + access_token=creation_token or bootstrap_token, json_body={ "workspace_id": engagement_id, "title": f"Private workspace {engagement_id[-8:]}", @@ -252,6 +269,104 @@ def host_workspace( self.push_workspace(service, engagement_id) return self.status(engagement_id) + def begin_host_authorization( + self, + service: AttackGraphService, + engagement_id: str, + *, + relay_url: str = DEFAULT_RELAY_URL, + actor_name: str = "", + ) -> dict[str, Any]: + if self.state.workspace(engagement_id): + return {"status": "hosted", **self.status(engagement_id)} + existing = self.state.pending_authorization(engagement_id) + if existing: + return { + "status": "approval_required", + "workspace_id": engagement_id, + "verification_url": existing["verification_url"], + "user_code": existing["user_code"], + "expires_at": existing["expires_at"], + "interval_seconds": existing["interval_seconds"], + } + service.memory.get_engagement(engagement_id) + actor_id, name, signing_key = self.state.identity(actor_name) + service.memory.actor_id = actor_id + service.memory.actor_name = name + base = _relay_url(relay_url) + payload = self._request( + "POST", + f"{base}/device/authorizations", + json_body={ + "actor_id": actor_id, + "actor_name": name, + "signing_public_key": _public_key(signing_key), + }, + ) + required = ( + "request_id", + "verification_url", + "user_code", + "poll_token", + "expires_at", + ) + if not all(payload.get(item) for item in required): + raise RelayError("device_authorization_response_invalid") + pending = { + "relay_url": base, + "request_id": str(payload["request_id"]), + "verification_url": str(payload["verification_url"]), + "user_code": str(payload["user_code"]), + "poll_token": str(payload["poll_token"]), + "expires_at": str(payload["expires_at"]), + "interval_seconds": max(1, int(payload.get("interval_seconds") or 2)), + "actor_name": name, + } + self.state.put_pending_authorization(engagement_id, pending) + return { + "status": "approval_required", + "workspace_id": engagement_id, + "verification_url": pending["verification_url"], + "user_code": pending["user_code"], + "expires_at": pending["expires_at"], + "interval_seconds": pending["interval_seconds"], + } + + def complete_host_authorization( + self, + service: AttackGraphService, + engagement_id: str, + ) -> dict[str, Any]: + pending = self.state.pending_authorization(engagement_id) + if not pending: + raise RelayError("device_authorization_not_started") + payload = self._request( + "POST", + f"{pending['relay_url']}/device/authorizations/{pending['request_id']}/token", + access_token=str(pending["poll_token"]), + ) + if payload.get("status") == "pending": + return { + "status": "pending", + "workspace_id": engagement_id, + "verification_url": pending["verification_url"], + "user_code": pending["user_code"], + "expires_at": pending["expires_at"], + "interval_seconds": pending["interval_seconds"], + } + creation_token = str(payload.get("creation_token") or "") + if payload.get("status") != "approved" or not creation_token: + raise RelayError("device_authorization_response_invalid") + result = self.host_workspace( + service, + engagement_id, + relay_url=str(pending["relay_url"]), + actor_name=str(pending.get("actor_name") or ""), + creation_token=creation_token, + ) + self.state.clear_pending_authorization(engagement_id) + return {"status": "hosted", **result} + def create_invite( self, workspace_id: str, diff --git a/src/h1dr4_attackgraph/relay_cli.py b/src/h1dr4_attackgraph/relay_cli.py index c37ca1d..2821d4a 100644 --- a/src/h1dr4_attackgraph/relay_cli.py +++ b/src/h1dr4_attackgraph/relay_cli.py @@ -3,6 +3,9 @@ import argparse import json import os +import sys +import time +import webbrowser from pathlib import Path from typing import Any @@ -43,6 +46,17 @@ def _parser() -> argparse.ArgumentParser: host.add_argument("engagement_id") host.add_argument("--relay", default=os.getenv("ATTACKGRAPH_RELAY_URL", DEFAULT_RELAY_URL)) host.add_argument("--name", default=os.getenv("ATTACKGRAPH_RELAY_ACTOR_NAME", "")) + host.add_argument( + "--no-browser", + action="store_true", + help="Print the approval URL without opening it.", + ) + host.add_argument( + "--approval-timeout", + type=int, + default=600, + help="Seconds to wait for passkey approval.", + ) invite = commands.add_parser("invite", help="Create a one-use encrypted join code.") invite.add_argument("engagement_id") @@ -74,13 +88,38 @@ def _run(args: argparse.Namespace) -> dict[str, Any]: return client.status() service = _service(state) if args.command == "host": - return client.host_workspace( + bootstrap_token = os.getenv("ATTACKGRAPH_RELAY_BOOTSTRAP_TOKEN", "") + if bootstrap_token: + return client.host_workspace( + service, + args.engagement_id, + relay_url=args.relay, + actor_name=args.name, + bootstrap_token=bootstrap_token, + ) + authorization = client.begin_host_authorization( service, args.engagement_id, relay_url=args.relay, actor_name=args.name, - bootstrap_token=os.getenv("ATTACKGRAPH_RELAY_BOOTSTRAP_TOKEN", ""), ) + if authorization.get("status") == "hosted": + return authorization + print( + f"Approve AttackGraph workspace creation:\n{authorization['verification_url']}\n" + f"Code: {authorization['user_code']}", + file=sys.stderr, + ) + if not args.no_browser: + webbrowser.open(str(authorization["verification_url"])) + deadline = time.monotonic() + max(30, int(args.approval_timeout)) + interval = max(1, int(authorization.get("interval_seconds") or 2)) + while time.monotonic() < deadline: + result = client.complete_host_authorization(service, args.engagement_id) + if result.get("status") == "hosted": + return result + time.sleep(interval) + raise RelayError("device_authorization_timed_out") if args.command == "invite": return client.create_invite(args.engagement_id, role=args.role, hours=args.hours) if args.command == "join": diff --git a/src/h1dr4_attackgraph/server.py b/src/h1dr4_attackgraph/server.py index 1f464b8..444e8a5 100644 --- a/src/h1dr4_attackgraph/server.py +++ b/src/h1dr4_attackgraph/server.py @@ -400,13 +400,25 @@ def attackgraph_host_private_workspace( relay_url: str = DEFAULT_RELAY_URL, actor_name: str = "", ) -> dict[str, Any]: - """Host an existing engagement through the H1DR4 ciphertext-only private relay.""" - return get_relay().client.host_workspace( - get_service(), + """Host via H1DR4. First call returns a passkey URL; call again after approval.""" + relay = get_relay().client + service = get_service() + bootstrap_token = os.getenv("ATTACKGRAPH_RELAY_BOOTSTRAP_TOKEN", "") + if bootstrap_token: + return relay.host_workspace( + service, + engagement_id, + relay_url=relay_url, + actor_name=actor_name, + bootstrap_token=bootstrap_token, + ) + if relay.state.pending_authorization(engagement_id): + return relay.complete_host_authorization(service, engagement_id) + return relay.begin_host_authorization( + service, engagement_id, relay_url=relay_url, actor_name=actor_name, - bootstrap_token=os.getenv("ATTACKGRAPH_RELAY_BOOTSTRAP_TOKEN", ""), ) diff --git a/tests/test_relay.py b/tests/test_relay.py index 0261a26..99f60ef 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -19,10 +19,32 @@ def __init__(self) -> None: self.events: list[dict] = [] self.tokens = {"bootstrap": "bootstrap"} self.members: dict[str, dict] = {} + self.device_approved = False def handler(self, request: httpx.Request) -> httpx.Response: path = request.url.path.removeprefix("/v1/attackgraph") body = json.loads(request.content or b"{}") + if request.method == "POST" and path == "/device/authorizations": + return self.response( + request, + 201, + { + "request_id": "h1d_fixture", + "verification_url": "https://h1dr4.dev/operation-red/attackgraph/authorize?request=h1d_fixture&code=DEMO-CODE", + "user_code": "DEMO-CODE", + "poll_token": "h1p_fixture", + "expires_at": "2099-01-01T00:00:00Z", + "interval_seconds": 1, + }, + ) + if request.method == "POST" and path == "/device/authorizations/h1d_fixture/token": + if not self.device_approved: + return self.response(request, 202, {"status": "pending"}) + return self.response( + request, + 200, + {"status": "approved", "creation_token": "h1w_fixture"}, + ) if request.method == "POST" and path == "/workspaces": self.tokens["owner-token"] = "owner" self.members["member-owner"] = { @@ -231,3 +253,41 @@ def test_relay_state_is_created_private_and_untrusted_host_is_rejected( relay_url="https://attacker.example/v1/attackgraph", bootstrap_token="must-not-leak", ) + + +def test_device_authorization_hosts_without_shared_bootstrap_secret( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("ATTACKGRAPH_RELAY_ALLOWED_HOSTS", "relay.test") + relay = RelayFixture() + client = AttackGraphRelayClient( + RelayStateStore(tmp_path / "owner-relay.json"), + client=httpx.Client(transport=httpx.MockTransport(relay.handler)), + ) + owner_service = service(tmp_path / "owner.db", "owner") + engagement = owner_service.open_engagement( + title="Authorized Target", + target="https://owned.example", + mode="local_lab", + scope="Authorized fixture", + target_allowlist=["https://owned.example"], + ) + + started = client.begin_host_authorization( + owner_service, + engagement["engagement_id"], + relay_url="https://relay.test/v1/attackgraph", + actor_name="WEB-01", + ) + assert started["status"] == "approval_required" + assert started["user_code"] == "DEMO-CODE" + assert "poll_token" not in started + waiting = client.complete_host_authorization(owner_service, engagement["engagement_id"]) + assert waiting["status"] == "pending" + + relay.device_approved = True + hosted = client.complete_host_authorization(owner_service, engagement["engagement_id"]) + assert hosted["status"] == "hosted" + assert hosted["workspaces"][0]["role"] == "owner" + assert client.state.pending_authorization(engagement["engagement_id"]) is None