Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,48 @@ 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
```

`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:

```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
Expand Down
164 changes: 164 additions & 0 deletions docs/PRIVATE_RELAY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# 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.

## 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 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:

```bash
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
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 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
`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
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.

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 \
uv run python scripts/smoke_private_relay.py \
--relay https://h1dr4.dev/api/v1/attackgraph
```
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
92 changes: 92 additions & 0 deletions scripts/smoke_private_relay.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading