antimatter-gateway 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: antimatter-gateway
3
+ Version: 0.1.0
4
+ Requires-Dist: websockets>=12.0
5
+ Requires-Dist: qrcode>=7.4.2
6
+ Requires-Dist: antimatter-shared-config
7
+ Requires-Dist: antimatter-shared-crypto
8
+ Requires-Dist: antimatter-shared-protocol
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "antimatter-gateway"
3
+ version = "0.1.0"
4
+ dependencies = [
5
+ "websockets>=12.0",
6
+ "qrcode>=7.4.2",
7
+ "antimatter-shared-config",
8
+ "antimatter-shared-crypto",
9
+ "antimatter-shared-protocol"
10
+ ]
11
+
12
+ [tool.uv.sources]
13
+ antimatter-shared-config = { path = "../shared-config" }
14
+ antimatter-shared-crypto = { path = "../shared-crypto" }
15
+ antimatter-shared-protocol = { path = "../shared-protocol" }
16
+
17
+ [project.scripts]
18
+ antimatter = "antimatter_gateway.server:main"
19
+ antimatter-pair = "antimatter_gateway.qr:main"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,71 @@
1
+ import urllib.parse
2
+ import qrcode
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ def generate_qr_payload(
8
+ cloudflare_url: str | None,
9
+ pairing_token: str,
10
+ gateway_x25519_pub: str,
11
+ client_id: str | None = None
12
+ ) -> str:
13
+ """
14
+ Generates the canonical Deep Link payload for Android pairing.
15
+ Includes the X25519 public key for the E2EE ECDH handshake.
16
+ """
17
+ # For now, default to local dev if cloudflare is off
18
+ base_url = "https://antimatter.saifmukhtar.dev/connect"
19
+
20
+ params = {
21
+ "url": cloudflare_url or "ws://127.0.0.1:8765",
22
+ "token": pairing_token,
23
+ "x25519_pub": gateway_x25519_pub
24
+ }
25
+
26
+ if client_id:
27
+ params["cid"] = client_id
28
+
29
+ query = urllib.parse.urlencode(params)
30
+ return f"{base_url}?{query}"
31
+
32
+ def print_qr_to_terminal(payload: str) -> None:
33
+ qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=4)
34
+ qr.add_data(payload)
35
+
36
+ try:
37
+ qr.print_ascii(invert=True)
38
+ except Exception:
39
+ logger.warning("Failed to print ASCII QR. Terminal might not support it.")
40
+
41
+ def main():
42
+ from antimatter_shared_config.config import load_config
43
+ config = load_config()
44
+ if not config.gateway_priv_x25519 or not config.pairing_token:
45
+ print("Error: Gateway not initialized. Please run 'antimatter-gateway' first.")
46
+ return
47
+
48
+ # We construct the public key from the private key if needed, or simply assume it's in config
49
+ # Actually, E2EESession handles derivation.
50
+ from antimatter_crypto.e2ee import E2EESession
51
+ e2ee = E2EESession(role="gateway", private_key_b64=config.gateway_priv_x25519)
52
+
53
+ payload = generate_qr_payload(
54
+ cloudflare_url=config.cloudflare_url,
55
+ pairing_token=config.pairing_token,
56
+ gateway_x25519_pub=e2ee.public_key_b64,
57
+ client_id=config.cloudflare_client_id
58
+ )
59
+
60
+ print("\n" + "="*50)
61
+ print("ANTIMATTER E2EE GATEWAY SECURE PAIRING")
62
+ print("="*50)
63
+ if config.cloudflare_url:
64
+ print(f"\nTunnel: {config.cloudflare_url}")
65
+
66
+ print("\nScan this QR Code with the Antimatter App:\n")
67
+ print_qr_to_terminal(payload)
68
+ print("="*50)
69
+
70
+ if __name__ == "__main__":
71
+ main()
@@ -0,0 +1,76 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ from antimatter_crypto.e2ee import E2EESession
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ class MessageRouter:
9
+ def __init__(self, gateway=None):
10
+ self.gateway = gateway
11
+ self.clients = set()
12
+ self.adapters = {} # id -> {"name": str, "ws": websocket}
13
+
14
+ def add_client(self, websocket):
15
+ self.clients.add(websocket)
16
+ # When a client connects, instantly send them the list of agents
17
+ asyncio.create_task(self.broadcast_system_state())
18
+
19
+ def remove_client(self, websocket):
20
+ self.clients.discard(websocket)
21
+
22
+ async def register_adapter(self, agent_id: str, agent_name: str, websocket):
23
+ self.adapters[agent_id] = {"name": agent_name, "ws": websocket}
24
+ await self.broadcast_system_state()
25
+
26
+ async def unregister_adapter(self, agent_id: str):
27
+ if agent_id in self.adapters:
28
+ del self.adapters[agent_id]
29
+ await self.broadcast_system_state()
30
+
31
+ async def broadcast_system_state(self):
32
+ # Implementation to send AVAILABLE_AGENTS to clients
33
+ if not self.clients or not self.gateway:
34
+ return
35
+
36
+ agents = [
37
+ {"id": aid, "name": info["name"], "status": "online"}
38
+ for aid, info in self.adapters.items()
39
+ ]
40
+ payload = {"type": "AVAILABLE_AGENTS", "agents": agents}
41
+
42
+ await self.broadcast_to_clients(payload, self.gateway.e2ee)
43
+
44
+ async def route_to_adapter(self, parsed_cmd: dict, e2ee: E2EESession, websocket):
45
+ """
46
+ Receives decrypted command from client, routes to local adapter.
47
+ """
48
+ agent_id = parsed_cmd.get("agentId")
49
+ if not agent_id:
50
+ logger.warning(f"No agentId specified in command: {parsed_cmd.get('type')}")
51
+ return
52
+
53
+ adapter = self.adapters.get(agent_id)
54
+ if not adapter:
55
+ logger.warning(f"Target agent {agent_id} is offline. Dropping command.")
56
+ return
57
+
58
+ try:
59
+ await adapter["ws"].send(json.dumps(parsed_cmd))
60
+ except Exception as e:
61
+ logger.error(f"Failed to route to adapter {agent_id}: {e}")
62
+
63
+ async def broadcast_to_clients(self, payload: dict, e2ee: E2EESession):
64
+ """
65
+ Takes raw JSON payload from an adapter, encrypts it with the
66
+ server-to-client key, and sends it to all Android apps.
67
+ """
68
+ plaintext = json.dumps(payload)
69
+ envelope = e2ee.encrypt(plaintext, direction="output")
70
+ payload_str = json.dumps(envelope)
71
+
72
+ for client in list(self.clients):
73
+ try:
74
+ await client.send(payload_str)
75
+ except Exception:
76
+ self.clients.discard(client)
@@ -0,0 +1,224 @@
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import time
5
+ import websockets
6
+ from websockets.exceptions import ConnectionClosed
7
+
8
+ from antimatter_shared_config.config import load_config, save_config, AntimatterConfig
9
+ from antimatter_crypto.auth import Ed25519Auth
10
+ from antimatter_crypto.e2ee import E2EESession
11
+ from .qr import generate_qr_payload, print_qr_to_terminal
12
+ from .tunnel import CloudflaredManager
13
+ from .router import MessageRouter
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Rate Limiting
18
+ _ip_failure_counts: dict[str, dict] = {}
19
+ RATE_LIMIT_MAX_FAILURES = 5
20
+ RATE_LIMIT_WINDOW = 60
21
+
22
+ def _record_failure(ip: str):
23
+ now = time.monotonic()
24
+ data = _ip_failure_counts.get(ip, {"count": 0, "reset_at": 0.0})
25
+ if now >= data["reset_at"]:
26
+ data = {"count": 0, "reset_at": now + RATE_LIMIT_WINDOW}
27
+ data["count"] += 1
28
+ _ip_failure_counts[ip] = data
29
+
30
+ class GatewayServer:
31
+ def __init__(self):
32
+ self.config = load_config()
33
+ self.auth = Ed25519Auth(self.config.private_key_pem)
34
+ self.router = MessageRouter(gateway=self)
35
+
36
+ # Initialize E2EE Gateway Session
37
+ self.e2ee = E2EESession(role="gateway", private_key_b64=self.config.gateway_priv_x25519)
38
+
39
+ # Persist keys if newly generated
40
+ needs_save = False
41
+ if not self.config.private_key_pem:
42
+ self.config.private_key_pem = self.auth.private_key_base64
43
+ self.config.pairing_token = self.auth.pairing_token
44
+ needs_save = True
45
+
46
+ if not self.config.gateway_priv_x25519:
47
+ self.config.gateway_priv_x25519 = self.e2ee.private_key_b64
48
+ needs_save = True
49
+
50
+ if needs_save:
51
+ save_config(self.config)
52
+
53
+ async def handler(self, websocket):
54
+ ip = getattr(websocket, "remote_address", ("unknown",))[0]
55
+ now = time.monotonic()
56
+
57
+ # 1. Rate Limiting Check
58
+ rate_data = _ip_failure_counts.get(ip)
59
+ if rate_data and rate_data["count"] >= RATE_LIMIT_MAX_FAILURES and now < rate_data["reset_at"]:
60
+ logger.warning(f"Rate limited connection rejected: {ip}")
61
+ await websocket.close(1008, "Rate Limited")
62
+ return
63
+
64
+ # 2. Origin Validation
65
+ # In a real setup, we check req.headers.origin against cloudflareaccess.com
66
+ # For simplicity in python websockets, we handle it natively or trust localhost.
67
+
68
+ authenticated = False
69
+ e2ee_established = False
70
+
71
+ try:
72
+ async for message in websocket:
73
+ try:
74
+ data = json.loads(message)
75
+ except Exception:
76
+ continue
77
+
78
+ msg_type = data.get("type")
79
+
80
+ msg_type = data.get("type")
81
+
82
+ # Adapter Registration (Local IPC)
83
+ if msg_type == "REGISTER_ADAPTER":
84
+ agent_id = data.get("id")
85
+ agent_name = data.get("name")
86
+ if agent_id and agent_name:
87
+ logger.info(f"Adapter registered: {agent_name} ({agent_id})")
88
+ await self.router.register_adapter(agent_id, agent_name, websocket)
89
+ # We break out of the auth loop into a dedicated adapter loop
90
+ await self._adapter_loop(websocket, agent_id)
91
+ return
92
+ else:
93
+ await websocket.close(1008, "Missing adapter id/name")
94
+ return
95
+
96
+ # State 1: Legacy Ed25519 Auth Challenge (Client)
97
+ if msg_type == "AUTH_CHALLENGE":
98
+ challenge = data.get("challenge")
99
+ if not challenge:
100
+ await websocket.close(1008, "Missing challenge")
101
+ return
102
+
103
+ sig = self.auth.sign_challenge(challenge)
104
+ await websocket.send(json.dumps({"type": "AUTH_RESPONSE", "signature": sig}))
105
+ authenticated = True
106
+ logger.info(f"Client {ip} authenticated successfully.")
107
+
108
+ # Add client to router once authenticated
109
+ self.router.add_client(websocket)
110
+ continue
111
+
112
+ if not authenticated:
113
+ _record_failure(ip)
114
+ await websocket.close(1008, "Unauthorized")
115
+ return
116
+
117
+ # State 2: E2EE Handshake
118
+ if msg_type == "HELLO":
119
+ client_pubkey = data.get("pubkey")
120
+ if not client_pubkey:
121
+ await websocket.close(1008, "Missing X25519 pubkey")
122
+ return
123
+
124
+ self.e2ee.derive_session_keys(client_pubkey)
125
+ e2ee_established = True
126
+ logger.info("E2EE Session established (ECDH + HKDF).")
127
+
128
+ # Notify adapters
129
+ await self.router.broadcast_system_state()
130
+ continue
131
+
132
+ if not e2ee_established:
133
+ await websocket.send(json.dumps({"type": "ERROR", "message": "E2EE Handshake required."}))
134
+ continue
135
+
136
+ # State 3: E2EE Encrypted Payload Routing
137
+ if "iv" in data and "ct" in data and "aad" in data:
138
+ try:
139
+ # Decrypt client->server command
140
+ plaintext = self.e2ee.decrypt(data, expected_direction="cmd:")
141
+ parsed_cmd = json.loads(plaintext)
142
+
143
+ # Route to local adapter via IPC
144
+ await self.router.route_to_adapter(parsed_cmd, self.e2ee, websocket)
145
+ except ValueError as e:
146
+ logger.error(f"E2EE Decryption/AAD failed: {e}")
147
+ await websocket.close(1008, "Encryption Error")
148
+ return
149
+ else:
150
+ logger.warning("Unencrypted message received post-handshake. Dropping.")
151
+
152
+ except ConnectionClosed:
153
+ logger.info(f"Connection closed: {ip}")
154
+ finally:
155
+ self.router.remove_client(websocket)
156
+
157
+ async def _adapter_loop(self, websocket, agent_id: str):
158
+ """
159
+ Dedicated loop for listening to incoming messages from a local adapter.
160
+ When an adapter sends a message, it needs to be broadcast or routed to clients.
161
+ """
162
+ try:
163
+ async for message in websocket:
164
+ try:
165
+ data = json.loads(message)
166
+ except Exception:
167
+ continue
168
+
169
+ # Currently we only have one e2ee session and one client.
170
+ # If we have multiple clients, we need to map adapter messages to clients.
171
+ # For now, we broadcast to all connected, authenticated clients.
172
+ await self.router.broadcast_to_clients(data, self.e2ee)
173
+
174
+ except ConnectionClosed:
175
+ logger.info(f"Adapter connection closed: {agent_id}")
176
+ finally:
177
+ await self.router.unregister_adapter(agent_id)
178
+
179
+ async def start(self):
180
+ logger.info("Starting Gateway WebSocket server on ws://127.0.0.1:8765")
181
+
182
+ # Start Tunnel if configured
183
+ tunnel_url = self.config.cloudflare_url
184
+ self.cf_manager = None
185
+
186
+ if not tunnel_url:
187
+ logger.info("Starting Cloudflare Quick Tunnel...")
188
+ self.cf_manager = CloudflaredManager(8765)
189
+ if await self.cf_manager.start():
190
+ try:
191
+ await asyncio.wait_for(self.cf_manager.ready_event.wait(), timeout=15.0)
192
+ tunnel_url = self.cf_manager.url
193
+ except asyncio.TimeoutError:
194
+ logger.warning("Quick tunnel timeout.")
195
+
196
+ payload = generate_qr_payload(
197
+ cloudflare_url=tunnel_url,
198
+ pairing_token=self.auth.pairing_token,
199
+ gateway_x25519_pub=self.e2ee.public_key_b64,
200
+ client_id=self.config.cloudflare_client_id
201
+ )
202
+
203
+ print("\n" + "="*50)
204
+ print("ANTIMATTER E2EE GATEWAY SECURE PAIRING")
205
+ print("="*50)
206
+ if tunnel_url:
207
+ print(f"\nTunnel: {tunnel_url}")
208
+
209
+ print("\nScan this QR Code with the Antimatter App:\n")
210
+ print_qr_to_terminal(payload)
211
+ print("="*50)
212
+
213
+ async with websockets.serve(self.handler, "127.0.0.1", 8765):
214
+ await asyncio.Future()
215
+
216
+ async def main_async():
217
+ server = GatewayServer()
218
+ await server.start()
219
+
220
+ def main():
221
+ asyncio.run(main_async())
222
+
223
+ if __name__ == "__main__":
224
+ main()
@@ -0,0 +1,52 @@
1
+ import asyncio
2
+ import re
3
+ import logging
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ class CloudflaredManager:
8
+ def __init__(self, port: int):
9
+ self.port = port
10
+ self.process = None
11
+ self.url = None
12
+ self.ready_event = asyncio.Event()
13
+
14
+ async def start(self):
15
+ try:
16
+ self.process = await asyncio.create_subprocess_exec(
17
+ "cloudflared", "tunnel", "--url", f"http://127.0.0.1:{self.port}",
18
+ stdout=asyncio.subprocess.PIPE,
19
+ stderr=asyncio.subprocess.PIPE
20
+ )
21
+
22
+ # Cloudflared prints to stderr
23
+ asyncio.create_task(self._read_stderr())
24
+ except FileNotFoundError:
25
+ logger.error("cloudflared CLI not found. Please install it to use Quick Tunnels.")
26
+ return False
27
+ return True
28
+
29
+ async def _read_stderr(self):
30
+ url_pattern = re.compile(r"https://[a-zA-Z0-9-]+\.trycloudflare\.com")
31
+ while True:
32
+ line = await self.process.stderr.readline()
33
+ if not line:
34
+ break
35
+ line_str = line.decode('utf-8').strip()
36
+
37
+ logger.info(f"[Cloudflared] {line_str}")
38
+
39
+ if not self.url:
40
+ match = url_pattern.search(line_str)
41
+ if match:
42
+ # Convert https to wss
43
+ self.url = match.group(0).replace("https://", "wss://")
44
+ self.ready_event.set()
45
+
46
+ async def stop(self):
47
+ if self.process:
48
+ self.process.terminate()
49
+ try:
50
+ await asyncio.wait_for(self.process.wait(), timeout=5.0)
51
+ except asyncio.TimeoutError:
52
+ self.process.kill()
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: antimatter-gateway
3
+ Version: 0.1.0
4
+ Requires-Dist: websockets>=12.0
5
+ Requires-Dist: qrcode>=7.4.2
6
+ Requires-Dist: antimatter-shared-config
7
+ Requires-Dist: antimatter-shared-crypto
8
+ Requires-Dist: antimatter-shared-protocol
@@ -0,0 +1,11 @@
1
+ pyproject.toml
2
+ src/antimatter_gateway/qr.py
3
+ src/antimatter_gateway/router.py
4
+ src/antimatter_gateway/server.py
5
+ src/antimatter_gateway/tunnel.py
6
+ src/antimatter_gateway.egg-info/PKG-INFO
7
+ src/antimatter_gateway.egg-info/SOURCES.txt
8
+ src/antimatter_gateway.egg-info/dependency_links.txt
9
+ src/antimatter_gateway.egg-info/entry_points.txt
10
+ src/antimatter_gateway.egg-info/requires.txt
11
+ src/antimatter_gateway.egg-info/top_level.txt
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ antimatter = antimatter_gateway.server:main
3
+ antimatter-pair = antimatter_gateway.qr:main
@@ -0,0 +1,5 @@
1
+ websockets>=12.0
2
+ qrcode>=7.4.2
3
+ antimatter-shared-config
4
+ antimatter-shared-crypto
5
+ antimatter-shared-protocol
@@ -0,0 +1 @@
1
+ antimatter_gateway