glypha 2.0.0__py3-none-any.whl

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,217 @@
1
+ Metadata-Version: 2.4
2
+ Name: glypha
3
+ Version: 2.0.0
4
+ Summary: P2P encrypted chat from raw Python sockets: E2E encryption, persistent identities, fingerprint verification, peer discovery, NAT traversal with relay fallback
5
+ Author: Adarsh Mishra
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/<your-github-username>/<repo-name>
8
+ Keywords: p2p,encryption,chat,sockets,nacl,networking,e2e
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Communications :: Chat
11
+ Classifier: Topic :: Education
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: pynacl>=1.5
15
+
16
+ # P2P Encrypted Chat
17
+
18
+ An educational peer-to-peer encrypted messaging system built from raw Python TCP sockets — custom message framing, persistent cryptographic identities, fingerprint-based peer authentication, encrypted local history, peer discovery via a cloud rendezvous server, and a measured NAT traversal experiment.
19
+
20
+ This is **not** a WebRTC/PeerJS wrapper. Every layer — framing, encryption, identity, storage, discovery — is built and understood from the socket up.
21
+
22
+ > ⚠️ **Educational project. Not Signal-grade security. Do not use for sensitive communications.** See [Security Model](#security-model) below.
23
+
24
+ ## Table of Contents
25
+
26
+ - [What It Does](#what-it-does)
27
+ - [Architecture](#architecture)
28
+ - [Usage](#usage)
29
+ - [The NAT Traversal Experiment](#the-nat-traversal-experiment)
30
+ - [Security Model](#security-model)
31
+ - [Repository Layout](#repository-layout)
32
+ - [Testing](#testing)
33
+ - [Bugs Found (and Fixed) Along the Way](#bugs-found-and-fixed-along-the-way)
34
+ - [Infrastructure Notes](#infrastructure-notes)
35
+ - [Roadmap (V2)](#roadmap-v2)
36
+
37
+ ## What It Does
38
+
39
+ | Capability | Proof |
40
+ |---|---|
41
+ | Two peers chat E2E-encrypted | Verified on two physical laptops over LAN, recorded on video |
42
+ | Identity survives restarts | Same fingerprint (`c849:a2b7...`) across every session of the build |
43
+ | History persists, encrypted at rest | Restart → history reloads and decrypts; raw SQL shows only ciphertext |
44
+ | Peer discovery by name | `find aadarsh` → endpoint + fingerprint, worked across two Indian carrier networks + Azure |
45
+ | Cross-network discovery over the real Internet | Rendezvous hosted on an Azure VM; peers on home Wi-Fi (Delhi) + mobile hotspot registered and looked each other up |
46
+ | Fingerprint verification | Both sides display fingerprints; out-of-band comparison; pre-connect verification via the registry |
47
+
48
+ ## Demo
49
+
50
+ 🎥 Two physical laptops chatting over LAN, disconnect → reconnect → history reloads.
51
+
52
+ | Fingerprint verification across two screens | Rendezvous log: two peers, two public NAT IPs | Punch verdict on CGNAT |
53
+ |---|---|---|
54
+ | The out-of-band fingerprint check, side by side | The registry holding two different public NAT mappings (home Wi-Fi + mobile hotspot) — discovery across the real Internet | Control pass on loopback vs. failure on real CGNAT |
55
+
56
+ *(Screenshots live in `screenshots/`.)*
57
+
58
+ ## Architecture
59
+
60
+ ```text
61
+ ┌──────────────────────┐
62
+ │ Rendezvous Server │
63
+ │ Discovery/Signaling │ (Azure VM, port 7000)
64
+ └──────────┬───────────┘
65
+ │ endpoint + fingerprint
66
+ ↙ ↘
67
+ ┌────────┐ ┌────────┐
68
+ │ Peer A │◄─►│ Peer B │ direct P2P chat (port 9999)
69
+ └────────┘ └────────┘
70
+ ```
71
+
72
+ Every layer of the transport stack is implemented, not imported:
73
+
74
+ ```text
75
+ Application (chat, history)
76
+
77
+ Peer management (listen / connect / find / punch modes)
78
+
79
+ Cryptographic identity (persistent keypairs, fingerprints)
80
+
81
+ PyNaCl Box (X25519 key exchange + XSalsa20-Poly1305 AEAD)
82
+
83
+ Custom framing ([4-byte big-endian length][payload])
84
+
85
+ TCP sockets (blocking, threaded receive)
86
+
87
+ IP / NAT / firewall
88
+ ```
89
+
90
+ Local storage is a separate concern from transport: every message is passed through `SecretBox.encrypt` before being written to a SQLite `BLOB`, using a storage key that is distinct from the transport identity key.
91
+
92
+ ## Usage
93
+
94
+ ```bash
95
+ git clone <repo> && cd p2p-chat
96
+ pip install pynacl
97
+
98
+ # terminal 1 — rendezvous server (or point at the deployed one)
99
+ python rendezvous_server.py # port 7000
100
+
101
+ # terminal 2 — peer A, discoverable by name
102
+ python peer.py listen 9999 aadarsh <rv_ip>
103
+
104
+ # terminal 3 — peer B finds and connects
105
+ python peer.py find aadarsh <rv_ip>
106
+ python peer.py connect <aadarsh-ip> 9999 ishu
107
+
108
+ # NAT traversal attempt (coordinated simultaneous open)
109
+ python peer.py punch <my_id> <peer_id> <my_port> <rv_ip>
110
+ ```
111
+
112
+ Once connected, both sides display their fingerprints. Compare them out-of-band (e.g. a phone call) before trusting the session. Type `quit` to exit — history reloads automatically the next time you connect to the same peer.
113
+
114
+ ## The NAT Traversal Experiment
115
+
116
+ This is the centerpiece of the V1 build: a real, measured attempt at direct P2P connectivity across the open Internet — not a simulated or hand-waved result.
117
+
118
+ **Setup:** Rendezvous server on an Azure VM (Central India). Peer A on home Wi-Fi (`192.168.1.8` local, NAT public `122.162.151.183`). Peer B on a phone hotspot (`10.197.183.135` local behind carrier-grade NAT, NAT public `157.49.119.123`).
119
+
120
+ | Test | Result |
121
+ |---|---|
122
+ | Baseline: uncoordinated direct dial across the Internet | ❌ `WinError 10060` timeout (~20s) — NAT drops the unsolicited inbound SYN |
123
+ | Control: simultaneous open on loopback | ✅ Punched through — hole-punching mechanics proven correct |
124
+ | Simultaneous open, home NAT ↔ Airtel CGNAT, 60s | ❌ Neither NAT delivered the peer's SYNs |
125
+
126
+ **Conclusion:** the code is correct (the loopback control passes); the network refuses. Carrier-grade NAT shares one public IP across thousands of subscribers with endpoint-dependent mapping, which direct TCP hole punching cannot reliably defeat. This is a measured failure, explained at the NAT level, not a bug.
127
+
128
+ **Fallback strategy (documented, V2):** a relay forwards already-encrypted traffic. The rendezvous/relay server never holds keys — by construction, it doesn't even have PyNaCl installed, so the "phone book" literally cannot read messages even if it wanted to.
129
+
130
+ ## Security Model
131
+
132
+ **Protects against:**
133
+ - Passive network observers (end-to-end AEAD encryption)
134
+ - Peer impersonation across reconnects (persistent identities + fingerprint verification)
135
+ - Plaintext history on disk (SQLite holds only ciphertext)
136
+ - IP changes redefining identity (history is keyed by fingerprint, not IP)
137
+
138
+ **Does NOT protect against:**
139
+ - **No forward secrecy** — static-static ECDH means a later key compromise exposes previously recorded traffic; there is no ratchet
140
+ - Unencrypted key files at rest
141
+ - Replay attacks (no counters or nonces yet)
142
+ - A malicious rendezvous server serving the wrong endpoint (fingerprint comparison mitigates this, but Trust-On-First-Use doesn't eliminate it)
143
+ - Traffic analysis / metadata leakage
144
+ - Machine compromise
145
+ - `peer_id` squatting — names aren't yet bound to keys (V2 pins names to the first-registered key)
146
+ - Implementation bugs not yet found
147
+
148
+ This is an educational project. **Do not use it for sensitive real-world communications.**
149
+
150
+ ## Repository Layout
151
+
152
+ ```text
153
+ p2p-chat/
154
+ ├── peer.py # unified peer: listen | connect | find | punch
155
+ ├── protocol.py # length-prefixed framing (OSError-as-EOF contract)
156
+ ├── identity.py # persistent transport + storage keys
157
+ ├── storage.py # encrypted SQLite history
158
+ ├── rendezvous_server.py # discovery/signaling (port 7000, TTL 90s)
159
+ ├── crypto_test.py # manual crypto sanity check
160
+ ├── tests/ # 19 pytest tests
161
+ ├── project.md # living engineering doc (milestones, threat model)
162
+ ├── README.md # this file
163
+ └── .gitignore # *.bin *.db *.pem __pycache__/ .pytest_cache/
164
+ ```
165
+
166
+ ## Testing
167
+
168
+ 19 automated tests across 3 files, running in ~0.53s:
169
+
170
+ - **`test_protocol.py` (8 tests):** roundtrips (small/empty/binary-256), back-to-back frames in one burst, fragmented delivery (header split mid-stream), 1 MiB through a threaded reader, clean-FIN → `None`, RST → `None` (regression for the M13 shutdown bug, reproduced via `SO_LINGER(0)`)
171
+ - **`test_storage.py` (6 tests):** roundtrip, insertion order (locks `ORDER BY id`), per-fingerprint isolation, message direction, ciphertext-at-rest (reads the raw SQL like an attacker would), wrong key → `CryptoError` (regression for the M12 key-orphaning incident)
172
+ - **`test_identity.py` (5 tests):** key persistence, distinct identities, fingerprint stability across reloads, fingerprint format contract (16×4 hex groups; stripping colons = raw SHA-256), `SecretBox` key size
173
+
174
+ ```bash
175
+ python -m pytest tests/ -v
176
+ # 19 passed in ~0.5s
177
+ ```
178
+
179
+ ## Bugs Found (and Fixed) Along the Way
180
+
181
+ Each of these was caught by the test suite or a real run, and each is now guarded against:
182
+
183
+ - **`Peer.py` vs `peer.py`** — Windows case-insensitivity masked this; caught on the first test run and would have broken on Linux/macOS
184
+ - **Windows `Ctrl+C` sends RST, not FIN** — the receive thread crashed with `ConnectionResetError`; fixed by treating `OSError` as EOF everywhere in `recv_message`, giving one consistent failure convention
185
+ - **Daemon thread killed mid-print at shutdown** (`Fatal Python error: _enter_buffered_busy`) — fixed by joining the receiver thread with a 2s timeout before exit
186
+ - **Phantom "Peer disconnected" on your own `quit`** — fixed with an `if connected:` guard
187
+ - **Storage-key rename orphaned encrypted history** — surfaces as a loud `CryptoError` by design; now locked by a regression test
188
+ - **`ORDER BY timestamp` scrambled messages at second-granularity** — switched to `ORDER BY id`
189
+ - **Fingerprint gate was exact-match and rejected `"y"`** — fixed with an explicit allowlist (`yes`/`y`); anything else still fails closed
190
+ - **Punch-mode split-brain connections** — fixed with identity-proof at accept: a connection must present the public key matching the registry fingerprint, or it's dropped
191
+ - **Windows failed-socket reuse** — fixed by using a fresh socket per punch attempt
192
+ - **`get_lan_ip()` originally used a TCP socket** — it would open a real connection to `8.8.8.8:80` (hanging ~20s when unreachable) instead of doing a route lookup — replaced with a UDP socket, which never sends a packet
193
+ - **Rendezvous lookup race** — fixed with a 20s retry loop so start order no longer matters
194
+ - **Python block-buffers stdout when redirected**, causing empty logs on an otherwise healthy server — fixed by running with `python3 -u`
195
+
196
+ ## Infrastructure Notes
197
+
198
+ Real cloud/ops lessons from deploying the rendezvous server:
199
+
200
+ - **Azure for Students** has region allowlist restrictions (`RequestDisallowedByAzure`) and a B-series vCPU quota of 0 in some regions; worked around with a `B2ats_v2` instance (~$0.0062/hr). No auto-shutdown was available in-region, so a manual portal-stop discipline plus a $15 budget alert was used instead.
201
+ - Two independent firewall layers must both be opened for the server to be reachable: the cloud Network Security Group **and** the OS-level `ufw`.
202
+ - A VM restart kills running processes but not files, so the server is started with `nohup python3 -u rendezvous_server.py &` every time; `pgrep` is checked first to avoid stale double instances (which caused a confusing false failure once).
203
+ - MinTTY/Git Bash swallows `Ctrl+C` for console-less programs — use `winpty`.
204
+ - The VM's public IP can change on deallocate, so it's always re-read from the Azure portal rather than assumed.
205
+ - The rendezvous server records the **observed** source IP of a connecting peer (which can't be lied to) alongside the **peer-advertised** listen port (which it has no way to independently verify). Registering via `127.0.0.1` poisons the registry — the same observed-vs-advertised mechanism is what later reveals real NAT public mappings.
206
+
207
+ ## Roadmap (V2)
208
+
209
+ - **Relay implementation** — the fallback for when punching fails; already-encrypted traffic is relayed, and the rendezvous server is upgraded to hand out relay info. This is what makes cross-Internet chat work reliably, not just when NAT cooperates.
210
+ - **Packaging** — a `pyproject.toml` and a PyPI release, so usage becomes `pip install <name>` followed by `p2pchat listen --name aadarsh` / `p2pchat connect aadarsh`, with no files, ports, or internals exposed to the end user.
211
+ - **Auto-fallback chain** — direct → punch → relay, orchestrated by a connection state machine.
212
+ - **Name pinning** — first registration binds a `peer_id` to a public key, closing the squatting gap.
213
+ - **Hardening** — `MAX_MESSAGE_SIZE`, replay counters, heartbeats/timeouts, and key-file permissions.
214
+
215
+ ---
216
+
217
+ *Stack: Python 3.13, stdlib `socket`/`threading`/`struct`/`sqlite3`/`json`, PyNaCl (libsodium bindings), pytest. No frameworks. ~5 core modules + rendezvous server, 19 automated tests, 15 milestones, ~4 weeks of build time.*
@@ -0,0 +1,11 @@
1
+ identity.py,sha256=harESp118v8-3CEc2r5bXqfffJ4P5i0js5fNHzJJnTo,733
2
+ peer.py,sha256=IwQRcx0F9yL0spqjQjZTwIG7JAQ8sLdRGVk1eIzyDto,25642
3
+ protocol.py,sha256=uPNzyuO0paH9_IXnvEqTLuXYRRHpQdLv1nZ5aj9k29g,1111
4
+ relay_server.py,sha256=9ThbZLjFcyrmcHUVK2hzk6TrfHahM6Olg7tT_e9iDvE,3538
5
+ rendezvous_server.py,sha256=EbD8LTFeo1uP4X5GSdRQPNCUDfRlOVYgR1xpOWEU17U,3241
6
+ storage.py,sha256=1ePEHII16LOWGC8IyjqhU5QRQGcAGoUSuYq7UrOtmuQ,1857
7
+ glypha-2.0.0.dist-info/METADATA,sha256=4t0biKOD_oDeyjEq2wkzvrKneQtXtcv8BMT6EjtGMTY,13428
8
+ glypha-2.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
9
+ glypha-2.0.0.dist-info/entry_points.txt,sha256=ENdd3OVmQlfAXnTYJpib9HmJW18ouD7yvtwasmnYRBo,113
10
+ glypha-2.0.0.dist-info/top_level.txt,sha256=KoM1JsBdoBDFpuAMhoUmGeny9twuq0drOoP846bT-vM,62
11
+ glypha-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ glypha = peer:main
3
+ glypha-relay = relay_server:main
4
+ glypha-rendezvous = rendezvous_server:main
@@ -0,0 +1,6 @@
1
+ identity
2
+ peer
3
+ protocol
4
+ relay_server
5
+ rendezvous_server
6
+ storage
identity.py ADDED
@@ -0,0 +1,31 @@
1
+ import os
2
+
3
+ from nacl.public import PrivateKey
4
+ from nacl.secret import SecretBox
5
+ from nacl.utils import random
6
+
7
+ def load_or_create_key(filename):
8
+ if os.path.exists(filename):
9
+ with open(filename, "rb") as file:
10
+ key_bytes = file.read()
11
+
12
+ return PrivateKey(key_bytes)
13
+
14
+ privateKey = PrivateKey.generate()
15
+
16
+ with open(filename, "wb") as file:
17
+ file.write(bytes(privateKey))
18
+
19
+ return privateKey
20
+
21
+ def load_or_create_secret_key(filename):
22
+ if os.path.exists(filename):
23
+ with open(filename, "rb") as file:
24
+ return file.read()
25
+
26
+ key = random(SecretBox.KEY_SIZE)
27
+
28
+ with open(filename, "wb") as file:
29
+ file.write(key)
30
+
31
+ return key
peer.py ADDED
@@ -0,0 +1,652 @@
1
+ # peer.py — unified P2P encrypted chat peer.
2
+ # Modes: listen | connect | find | punch | relay | chat
3
+ # "chat" is the product command: direct -> punch -> relay, automatic.
4
+
5
+ import socket
6
+ import threading
7
+ import sys
8
+ import hashlib
9
+ import json
10
+ import base64
11
+ import time
12
+
13
+ from nacl.public import PrivateKey, PublicKey, Box
14
+ from nacl.secret import SecretBox
15
+ from protocol import recv_message, send_message
16
+ from identity import load_or_create_key, load_or_create_secret_key
17
+ from storage import init_db, save_message, load_messages
18
+
19
+ RENDEZVOUS_PORT = 7000
20
+ RENDEZVOUS_REFRESH = 30 # server TTL is 90s; refresh at 1/3 of TTL
21
+ RELAY_PORT = 7001
22
+
23
+
24
+ # ───────────────────────── helpers ─────────────────────────
25
+
26
+ def get_lan_ip():
27
+ """Our LAN IP via a UDP route lookup — no packet is ever sent.
28
+ (A TCP socket here would open a real connection to 8.8.8.8:80.)"""
29
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
30
+ try:
31
+ s.connect(("8.8.8.8", 80))
32
+ ip = s.getsockname()[0]
33
+ except OSError:
34
+ ip = "127.0.0.1"
35
+ finally:
36
+ s.close()
37
+ return ip
38
+
39
+
40
+ def format_fingerprint(public_bytes):
41
+ """SHA-256 of a public key, grouped 4-hex-chunks — the string
42
+ two humans compare out-of-band to authenticate each other."""
43
+ fp = hashlib.sha256(public_bytes).hexdigest()
44
+ return ":".join(fp[i:i + 4] for i in range(0, len(fp), 4))
45
+
46
+
47
+ def punch_port_for(name):
48
+ """Deterministic per-name port (20000-39999): two peers on one machine
49
+ never collide, and an identity always punches from the same port."""
50
+ h = int(hashlib.sha256(name.encode()).hexdigest(), 16)
51
+ return 20000 + (h % 20000)
52
+
53
+
54
+ def handshake(sock, private_key, initiator):
55
+ """Exchange public keys and build the Box.
56
+ initiator=True -> send ours first, then receive theirs
57
+ initiator=False -> receive theirs first, then send ours
58
+ Returns (box, peer_fingerprint) or (None, None) on disconnect."""
59
+ own_public = bytes(private_key.public_key)
60
+ if initiator:
61
+ send_message(sock, own_public)
62
+ peer_bytes = recv_message(sock)
63
+ else:
64
+ peer_bytes = recv_message(sock)
65
+ send_message(sock, own_public)
66
+ if peer_bytes is None:
67
+ return None, None
68
+ box = Box(private_key, PublicKey(peer_bytes))
69
+ return box, format_fingerprint(peer_bytes)
70
+
71
+
72
+ def verify_fingerprints(own_fp, peer_fp):
73
+ """Manual out-of-band verification (TOFU): the human compares the
74
+ two fingerprints (e.g. on a phone call) and confirms."""
75
+ print("Your fingerprint:", own_fp)
76
+ print("Peer fingerprint:", peer_fp)
77
+ answer = input("Confirm peer fingerprint matches out-of-band (yes/no): ")
78
+ return answer.lower() in ("yes", "y")
79
+
80
+
81
+ KNOWN_PEERS_FILE = "known_peers.json" # local trust pins — gitignored
82
+
83
+ def load_known_peers():
84
+ try:
85
+ with open(KNOWN_PEERS_FILE) as f:
86
+ return json.load(f)
87
+ except (FileNotFoundError, json.JSONDecodeError):
88
+ return {}
89
+
90
+
91
+ def pin_peer(name, fingerprint):
92
+ known = load_known_peers()
93
+ known[name] = fingerprint
94
+ with open(KNOWN_PEERS_FILE, "w") as f:
95
+ json.dump(known, f, indent=2)
96
+
97
+ def verify_or_reject(own_fp, peer_fp, expected_fp, peer_name=None):
98
+ """Trust model (SSH known_hosts style):
99
+ - pinned before? auto-verify against the PIN, not the registry.
100
+ A changed key = loud warning + reject (possible impersonation).
101
+ - first contact? the human verifies out-of-band — the real TOFU
102
+ moment — then we pin for all future contacts.
103
+ expected_fp (registry) is only a cross-check, never a trust source."""
104
+ clean = peer_fp.replace(":", "")
105
+ pinned = load_known_peers().get(peer_name) if peer_name else None
106
+
107
+ if pinned:
108
+ if clean == pinned.replace(":", ""):
109
+ print(f"[chat] '{peer_name}' verified against pinned key")
110
+ return True
111
+ print(f"[chat] KEY CHANGE for '{peer_name}'!")
112
+ print(f" pinned: {pinned}")
113
+ print(f" got: {peer_fp}")
114
+ print("[chat] possible impersonation — rejecting.")
115
+ return False
116
+
117
+ # first contact: the human decides, then we pin
118
+ if verify_fingerprints(own_fp, peer_fp):
119
+ if peer_name:
120
+ pin_peer(peer_name, clean)
121
+ print(f"[chat] pinned '{peer_name}' for future auto-verification")
122
+ return True
123
+ return False
124
+
125
+
126
+ # ───────────────────────── chat (shared by every mode) ─────────────────────────
127
+
128
+ def chat(sock, box, peer_fp, name):
129
+ """The conversation loop: load history, run a receive thread and an
130
+ input/send loop, store every message encrypted, exit cleanly."""
131
+ storage_key = load_or_create_secret_key(f"{name}_storage_key.bin")
132
+ secret_box = SecretBox(storage_key)
133
+ db_filename = f"{name}_history.db"
134
+
135
+ init_db(db_filename)
136
+
137
+ for direction, text, timestamp in load_messages(db_filename, peer_fp, secret_box):
138
+ print(f"{'You' if direction == 'sent' else 'Them'}: {text}")
139
+
140
+ print("Secure connection established!")
141
+
142
+ connected = True
143
+
144
+ def receive_loop():
145
+ nonlocal connected
146
+ while True:
147
+ data = recv_message(sock)
148
+ if data is None:
149
+ if connected: # announce only if WE didn't initiate the close
150
+ print("\nPeer disconnected.")
151
+ connected = False
152
+ break
153
+ try:
154
+ message = box.decrypt(data).decode()
155
+ except Exception:
156
+ print("\nReceived an undecryptable frame — ignored.")
157
+ continue
158
+ save_message(db_filename, peer_fp, "received", message, secret_box)
159
+ print("Them:", message)
160
+
161
+ receiver = threading.Thread(target=receive_loop, daemon=True)
162
+ receiver.start()
163
+
164
+ while connected:
165
+ try:
166
+ message = input(f"{name}: ")
167
+ except KeyboardInterrupt:
168
+ break
169
+ if not connected:
170
+ print("Peer is gone.")
171
+ break
172
+ if message == "quit":
173
+ break
174
+ if not message.strip(): # bare Enter / whitespace: send nothing
175
+ continue
176
+ try:
177
+ send_message(sock, box.encrypt(message.encode()))
178
+ except OSError:
179
+ print("Peer is gone.")
180
+ break
181
+ save_message(db_filename, peer_fp, "sent", message, secret_box)
182
+
183
+ # Mark closed before closing the socket (no phantom "Peer disconnected."
184
+ # from our own close), then join the receiver: a daemon thread killed
185
+ # mid-print at shutdown can deadlock stdout (_enter_buffered_busy).
186
+ connected = False
187
+ sock.close()
188
+ receiver.join(timeout=2)
189
+
190
+
191
+ # ───────────────────────── rendezvous client ─────────────────────────
192
+
193
+ def rendezvous_register(private_key, name, listen_port, rv_host):
194
+ """Publish our name + listen port + public key. The socket is bound
195
+ to our listen port so the server observes the NAT mapping for it.
196
+ Returns the response dict, or None if unreachable."""
197
+ request = {
198
+ "type": "register",
199
+ "id": name,
200
+ "listen_port": listen_port,
201
+ "public_key": base64.b64encode(bytes(private_key.public_key)).decode(),
202
+ }
203
+ try:
204
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
205
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
206
+ sock.bind(("0.0.0.0", listen_port))
207
+ sock.settimeout(5)
208
+ sock.connect((rv_host, RENDEZVOUS_PORT))
209
+ send_message(sock, json.dumps(request).encode())
210
+ resp = recv_message(sock)
211
+ sock.close()
212
+ return json.loads(resp.decode()) if resp else None
213
+ except OSError:
214
+ return None
215
+
216
+
217
+ def rendezvous_lookup(peer_id, rv_host):
218
+ """Ask the rendezvous where a peer is. Returns response dict or None."""
219
+ try:
220
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
221
+ sock.settimeout(5)
222
+ sock.connect((rv_host, RENDEZVOUS_PORT))
223
+ send_message(sock, json.dumps({"type": "lookup", "id": peer_id}).encode())
224
+ resp = recv_message(sock)
225
+ sock.close()
226
+ return json.loads(resp.decode()) if resp else None
227
+ except OSError:
228
+ return None
229
+
230
+
231
+ def rendezvous_refresh_loop(private_key, name, listen_port, rv_host):
232
+ """Re-register every 30s so the 90s TTL never expires while we run."""
233
+ while True:
234
+ time.sleep(RENDEZVOUS_REFRESH)
235
+ if rendezvous_register(private_key, name, listen_port, rv_host) is None:
236
+ print("[rendezvous] refresh failed — will retry")
237
+
238
+
239
+ # ───────────────────────── explicit modes (internals) ─────────────────────────
240
+
241
+ def listen_mode(port, name, rv_host=None):
242
+ """Wait for one inbound peer, then chat. Optional rendezvous
243
+ registration makes us discoverable by name."""
244
+ private_key = load_or_create_key(f"{name}_key.bin")
245
+ own_fp = format_fingerprint(bytes(private_key.public_key))
246
+
247
+ server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
248
+ server.bind(("0.0.0.0", port))
249
+ server.listen()
250
+
251
+ print("Your fingerprint:", own_fp)
252
+ print(f"Listening on 0.0.0.0:{port}")
253
+ print(f"Other peer connects with: glypha connect {get_lan_ip()} {port} <their-name>")
254
+
255
+ if rv_host:
256
+ # discovery is optional infrastructure: if it's down, chat still works
257
+ resp = rendezvous_register(private_key, name, port, rv_host)
258
+ print(f"[rendezvous] registered — discoverable as '{name}'") if resp \
259
+ else print("[rendezvous] registration failed (continuing without discovery)")
260
+ threading.Thread(
261
+ target=rendezvous_refresh_loop,
262
+ args=(private_key, name, port, rv_host),
263
+ daemon=True,
264
+ ).start()
265
+
266
+ sock, address = server.accept()
267
+ server.close() # one session per run; multi-session accept loop is future work
268
+ print("Peer connected:", address)
269
+
270
+ box, peer_fp = handshake(sock, private_key, initiator=False)
271
+ if box is None:
272
+ print("Peer disconnected during handshake.")
273
+ return
274
+ if not verify_fingerprints(own_fp, peer_fp):
275
+ print("Fingerprint not verified — closing.")
276
+ sock.close()
277
+ return
278
+ chat(sock, box, peer_fp, name)
279
+
280
+
281
+ def connect_mode(host, port, name):
282
+ """Dial a known host:port directly, then chat."""
283
+ private_key = load_or_create_key(f"{name}_key.bin")
284
+ own_fp = format_fingerprint(bytes(private_key.public_key))
285
+
286
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
287
+ sock.connect((host, port))
288
+ print(f"Connected to {host}:{port}")
289
+
290
+ box, peer_fp = handshake(sock, private_key, initiator=True)
291
+ if box is None:
292
+ print("Peer disconnected during handshake.")
293
+ return
294
+ if not verify_fingerprints(own_fp, peer_fp):
295
+ print("Fingerprint not verified — closing.")
296
+ sock.close()
297
+ return
298
+ chat(sock, box, peer_fp, name)
299
+
300
+
301
+ def find_mode(peer_id, rv_host):
302
+ """One-shot discovery: print where a peer is + its fingerprint."""
303
+ result = rendezvous_lookup(peer_id, rv_host)
304
+ if result is None:
305
+ print(f"Rendezvous server at {rv_host}:{RENDEZVOUS_PORT} unreachable")
306
+ return
307
+ if result.get("status") == "found":
308
+ print(f"Peer '{peer_id}' is at {result['ip']}:{result['port']}")
309
+ print(f"Fingerprint: {result['fingerprint']}")
310
+ print("Verify this fingerprint with the peer out-of-band, then:")
311
+ print(f" glypha connect {result['ip']} {result['port']} <your-name>")
312
+ else:
313
+ print(f"'{peer_id}' not found (never registered, or entry expired)")
314
+
315
+
316
+ def punch_mode(my_id, peer_id, my_port, rv_host):
317
+ """Manual NAT-traversal attempt (debug/teaching mode): register,
318
+ find the peer, run the simultaneous open, chat on success."""
319
+ private_key = load_or_create_key(f"{my_id}_key.bin")
320
+ own_public = bytes(private_key.public_key)
321
+ own_fp = format_fingerprint(own_public)
322
+
323
+ if rendezvous_register(private_key, my_id, my_port, rv_host) is None:
324
+ print("Rendezvous unreachable — cannot punch")
325
+ return
326
+
327
+ target = None
328
+ dl = time.time() + 20 # peer may still be registering
329
+ while time.time() < dl:
330
+ target = rendezvous_lookup(peer_id, rv_host)
331
+ if target and target.get("status") == "found":
332
+ break
333
+ time.sleep(1)
334
+ if target is None or target.get("status") != "found":
335
+ print(f"'{peer_id}' never registered within 20s")
336
+ return
337
+
338
+ t_ip = target["ip"]
339
+ t_port = target.get("punch_port") or target["port"]
340
+ peer_fp_clean = target["fingerprint"].replace(":", "")
341
+ i_am_connector = own_fp.replace(":", "") < peer_fp_clean # deterministic roles
342
+
343
+ print(f"Punching {t_ip}:{t_port} for 60s — start the other side too!")
344
+ print("My role:", "connector (I dial)" if i_am_connector else "accepter (I wait)")
345
+
346
+ result = _punch_socket(private_key, own_public, t_ip, t_port,
347
+ peer_fp_clean, i_am_connector, my_port, 60)
348
+ if result is None:
349
+ print("Punch failed after 60s — that is the measured result. Screenshot it.")
350
+ return
351
+
352
+ winner, peer_public = result
353
+ print("PUNCHED THROUGH!", winner.getsockname(), "->", winner.getpeername())
354
+ chat(winner, Box(private_key, PublicKey(peer_public)),
355
+ format_fingerprint(peer_public), my_id)
356
+
357
+
358
+ def _punch_socket(private_key, own_public, t_ip, t_port, peer_fp_clean,
359
+ i_am_connector, my_port, window_s):
360
+ """Simultaneous-open core. Connector repeatedly dials the peer's public
361
+ punch endpoint FROM our punch port; accepter waits for inbound on it.
362
+ Identity-proof at accept: a connection must present the public key
363
+ matching the registry fingerprint, or it is dropped. Returns
364
+ (sock, peer_public_bytes) or None."""
365
+ verified = []
366
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
367
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
368
+ listener.bind(("0.0.0.0", my_port))
369
+ listener.listen()
370
+
371
+ deadline = time.time() + window_s
372
+
373
+ def try_accept():
374
+ while time.time() < deadline and not verified:
375
+ try:
376
+ conn, _ = listener.accept()
377
+ except OSError:
378
+ return
379
+ proof = recv_message(conn)
380
+ if proof is not None and hashlib.sha256(proof).hexdigest() == peer_fp_clean:
381
+ verified.append((conn, proof))
382
+ else:
383
+ conn.close() # junk, stale attempt, or duplicate process
384
+
385
+ threading.Thread(target=try_accept, daemon=True).start()
386
+
387
+ winner = None
388
+ while time.time() < deadline and winner is None:
389
+ if i_am_connector:
390
+ # fresh socket per attempt: Windows retires a socket after a
391
+ # failed connect, and retrying on it would fail forever
392
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
393
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
394
+ s.bind(("0.0.0.0", my_port)) # THE punch: same local port every attempt
395
+ s.settimeout(2)
396
+ try:
397
+ s.connect((t_ip, t_port))
398
+ send_message(s, own_public) # identify ourselves IMMEDIATELY
399
+ winner = s
400
+ except OSError:
401
+ pass
402
+ elif verified:
403
+ winner = verified[0][0]
404
+ break
405
+ time.sleep(0.5)
406
+
407
+ listener.close()
408
+ if winner is None:
409
+ return None
410
+
411
+ winner.settimeout(None) # chat sockets must block, not time out
412
+
413
+ if i_am_connector:
414
+ peer_bytes = recv_message(winner) # accepter answers with its key
415
+ if peer_bytes is None or hashlib.sha256(peer_bytes).hexdigest() != peer_fp_clean:
416
+ winner.close()
417
+ return None
418
+ peer_public = peer_bytes
419
+ else:
420
+ send_message(winner, own_public) # answer the proof
421
+ peer_public = verified[0][1] # already verified at accept
422
+
423
+ return winner, peer_public
424
+
425
+
426
+ def relay_mode(my_name, peer_name, relay_host):
427
+ """Manual relay chat (debug/teaching mode)."""
428
+ result = _relay_socket(my_name, peer_name, relay_host)
429
+ if result is None:
430
+ return
431
+ sock, box, peer_fp = result
432
+ private_key = load_or_create_key(f"{my_name}_key.bin")
433
+ if not verify_fingerprints(format_fingerprint(bytes(private_key.public_key)), peer_fp):
434
+ print("Fingerprint not verified — closing.")
435
+ sock.close()
436
+ return
437
+ chat(sock, box, peer_fp, my_name)
438
+
439
+
440
+ def _relay_socket(my_name, peer_name, relay_host):
441
+ """Join the relay and handshake through the pipe. Join protocol:
442
+ both peers send {id, target}; when they name each other mutually the
443
+ relay splices the sockets. Only the SECOND joiner gets a 'matched'
444
+ reply — the first joiner's very next bytes are the peer's handshake
445
+ key through the pipe (transparent from splice onward).
446
+ Returns (sock, box, peer_fp) or None."""
447
+ private_key = load_or_create_key(f"{my_name}_key.bin")
448
+
449
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
450
+ sock.settimeout(15)
451
+ try:
452
+ sock.connect((relay_host, RELAY_PORT))
453
+ except OSError:
454
+ return None
455
+
456
+ send_message(sock, json.dumps(
457
+ {"type": "relay_join", "id": my_name, "target": peer_name}).encode())
458
+
459
+ resp_raw = recv_message(sock)
460
+ if resp_raw is None:
461
+ sock.close()
462
+ return None
463
+ status = json.loads(resp_raw.decode()).get("status")
464
+ if status not in ("waiting", "matched"):
465
+ sock.close()
466
+ return None
467
+
468
+ # waiting -> joined first -> handshake responder
469
+ # matched -> joined second -> handshake initiator
470
+ sock.settimeout(120) # bounded wait for the peer; not forever
471
+ box, peer_fp = handshake(sock, private_key, initiator=(status == "matched"))
472
+ if box is None:
473
+ sock.close()
474
+ return None
475
+ sock.settimeout(None) # chat must block again
476
+ return sock, box, peer_fp
477
+
478
+
479
+ # ───────────────────── V2.3: chat mode (the product command) ─────────────────────
480
+
481
+ def try_direct(host, port, private_key, own_fp, expected_fp, peer_name):
482
+ """Ladder rung 1: dial the rendezvous-published endpoint directly
483
+ (works on LAN / public endpoints). Returns (sock, box, peer_fp) or None."""
484
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
485
+ sock.settimeout(8) # fast fail — a dead NAT path must not stall the ladder
486
+ try:
487
+ sock.connect((host, port))
488
+ except OSError:
489
+ return None
490
+ sock.settimeout(None)
491
+
492
+ box, peer_fp = handshake(sock, private_key, initiator=True)
493
+ if box is None:
494
+ return None
495
+ if not verify_or_reject(own_fp, peer_fp, expected_fp, peer_name):
496
+ sock.close()
497
+ return None
498
+ return sock, box, peer_fp
499
+
500
+
501
+ def try_punch(my_name, peer_name, private_key, own_fp, rv_host, expected_fp):
502
+ """Ladder rung 2: coordinated simultaneous open with shortened windows
503
+ so hostile NATs reach the relay quickly. Returns (sock, box, peer_fp) or None."""
504
+ my_port = punch_port_for(my_name)
505
+ own_public = bytes(private_key.public_key)
506
+
507
+ if rendezvous_register(private_key, my_name, my_port, rv_host) is None:
508
+ return None
509
+
510
+ target = None
511
+ dl = time.time() + 10 # peer is walking the same ladder; give them time to register
512
+ while time.time() < dl:
513
+ target = rendezvous_lookup(peer_name, rv_host)
514
+ if target and target.get("status") == "found":
515
+ break
516
+ time.sleep(1)
517
+ if target is None or target.get("status") != "found":
518
+ return None
519
+
520
+ t_ip = target["ip"]
521
+ t_port = target.get("punch_port") or target["port"]
522
+ peer_fp_clean = target["fingerprint"].replace(":", "")
523
+ i_am_connector = own_fp.replace(":", "") < peer_fp_clean
524
+
525
+ result = _punch_socket(private_key, own_public, t_ip, t_port,
526
+ peer_fp_clean, i_am_connector, my_port, 15)
527
+ if result is None:
528
+ return None
529
+
530
+ winner, peer_public = result
531
+ peer_fp = format_fingerprint(peer_public)
532
+ if not verify_or_reject(own_fp, peer_fp, expected_fp, peer_name):
533
+ winner.close()
534
+ return None
535
+ return winner, Box(private_key, PublicKey(peer_public)), peer_fp
536
+
537
+
538
+ def try_relay(my_name, peer_name, private_key, own_fp, rv_host, expected_fp):
539
+ """Ladder rung 3: the always-works path (V2.1). Returns (sock, box, peer_fp) or None."""
540
+ result = _relay_socket(my_name, peer_name, rv_host)
541
+ if result is None:
542
+ return None
543
+ sock, box, peer_fp = result
544
+ if not verify_or_reject(own_fp, peer_fp, expected_fp, peer_name):
545
+ sock.close()
546
+ return None
547
+ return sock, box, peer_fp
548
+
549
+
550
+ def chat_mode(my_name, peer_name, rv_host):
551
+ """The product command: one command in, a verified encrypted chat out.
552
+ Ladder: direct -> punch -> relay. The user never chooses a transport
553
+ and never sees a traceback."""
554
+ private_key = load_or_create_key(f"{my_name}_key.bin")
555
+ own_fp = format_fingerprint(bytes(private_key.public_key))
556
+
557
+ print(f"[chat] reaching '{peer_name}' via {rv_host}...")
558
+
559
+ # ONE lookup up front: drives rung 1 AND rendezvous-assisted verification
560
+ info = rendezvous_lookup(peer_name, rv_host)
561
+ expected_fp = None
562
+ result = None
563
+
564
+ if info is not None and info.get("status") == "found":
565
+ expected_fp = info.get("fingerprint")
566
+ print(f"[chat] trying direct connection to {info['ip']}:{info['port']}...")
567
+ result = try_direct(info["ip"], info["port"], private_key, own_fp, expected_fp, peer_name)
568
+ if result is None:
569
+ print("[chat] direct failed — trying NAT traversal...")
570
+ else:
571
+ print(f"[chat] '{peer_name}' not in rendezvous — skipping direct, trying traversal...")
572
+
573
+ if result is None:
574
+ result = try_punch(my_name, peer_name, private_key, own_fp, rv_host, expected_fp)
575
+ if result is None:
576
+ print("[chat] traversal failed — falling back to relay...")
577
+
578
+ if result is None:
579
+ result = try_relay(my_name, peer_name, private_key, own_fp, rv_host, expected_fp)
580
+ if result is None:
581
+ print(f"[chat] could not reach '{peer_name}'.")
582
+ print(f" Are they running: glypha chat{peer_name} {my_name} {rv_host}")
583
+ return
584
+
585
+ sock, box, peer_fp = result
586
+ print("[chat] connected!")
587
+ chat(sock, box, peer_fp, my_name)
588
+
589
+
590
+ # ───────────────────────── dispatch ─────────────────────────
591
+
592
+ def main():
593
+ if len(sys.argv) < 2:
594
+ print("usage:")
595
+ print(" glypha chat <my_name> <peer_name> [rv_host] # product command")
596
+ print(" glypha listen [port] [name] [rv_host]")
597
+ print(" glypha connect <host> [port] [name]")
598
+ print(" glypha find <peer_id> [rv_host]")
599
+ print(" glypha punch <my_id> <peer_id> [my_port] [rv_host]")
600
+ print(" glypha relay <my_id> <peer_id> <relay_host>")
601
+ sys.exit(1)
602
+
603
+ mode = sys.argv[1]
604
+
605
+ if mode == "listen":
606
+ port = int(sys.argv[2]) if len(sys.argv) > 2 else 9999
607
+ name = sys.argv[3] if len(sys.argv) > 3 else "peer"
608
+ rv_host = sys.argv[4] if len(sys.argv) > 4 else None
609
+ listen_mode(port, name, rv_host)
610
+
611
+ elif mode == "connect":
612
+ if len(sys.argv) < 3:
613
+ print("connect requires a host"); sys.exit(1)
614
+ host = sys.argv[2]
615
+ port = int(sys.argv[3]) if len(sys.argv) > 3 else 9999
616
+ name = sys.argv[4] if len(sys.argv) > 4 else "peer"
617
+ connect_mode(host, port, name)
618
+
619
+ elif mode == "find":
620
+ if len(sys.argv) < 3:
621
+ print("find requires a peer id"); sys.exit(1)
622
+ find_mode(sys.argv[2], sys.argv[3] if len(sys.argv) > 3 else "127.0.0.1")
623
+
624
+ elif mode == "punch":
625
+ if len(sys.argv) < 4:
626
+ print("usage: glypha punch <my_id> <peer_id> [my_port] [rv_host]")
627
+ sys.exit(1)
628
+ my_id, peer_id = sys.argv[2], sys.argv[3]
629
+ my_port = int(sys.argv[4]) if len(sys.argv) > 4 else 9999
630
+ rv = sys.argv[5] if len(sys.argv) > 5 else "127.0.0.1"
631
+ punch_mode(my_id, peer_id, my_port, rv)
632
+
633
+ elif mode == "relay":
634
+ if len(sys.argv) < 5:
635
+ print("usage: glypha relay <my_id> <peer_id> <relay_host>")
636
+ sys.exit(1)
637
+ relay_mode(sys.argv[2], sys.argv[3], sys.argv[4])
638
+
639
+ elif mode == "chat":
640
+ if len(sys.argv) < 4:
641
+ print("usage: glypha chat <my_name> <peer_name> [rv_host]")
642
+ sys.exit(1)
643
+ rv = sys.argv[4] if len(sys.argv) > 4 else "127.0.0.1"
644
+ chat_mode(sys.argv[2], sys.argv[3], rv)
645
+
646
+ else:
647
+ print("unknown mode:", mode)
648
+ sys.exit(1)
649
+
650
+
651
+ if __name__ == "__main__":
652
+ main()
protocol.py ADDED
@@ -0,0 +1,42 @@
1
+ import struct
2
+
3
+ def send_message(sock, data):
4
+
5
+ #get no of length of data
6
+ length = len(data)
7
+
8
+ #convert length in 4 bytes
9
+ header = struct.pack("!I", length)
10
+
11
+ #send header first
12
+ sock.sendall(header) #!sendall() keeps sending until all the provided data has been sent, or an error occurs.
13
+
14
+ #send the real meaasge
15
+ sock.sendall(data)
16
+
17
+
18
+ def recv_message(sock):
19
+ try:
20
+ header = b""
21
+ while len(header) < 4:
22
+ chunk = sock.recv(4 - len(header))
23
+ if not chunk:
24
+ return None
25
+ header += chunk
26
+
27
+ length = struct.unpack("!I", header)[0]
28
+
29
+ data = b""
30
+ while len(data) < length:
31
+ chunk = sock.recv(length - len(data))
32
+ if not chunk:
33
+ return None
34
+ data += chunk
35
+
36
+ return data
37
+
38
+ except OSError:
39
+ # ConnectionResetError (WinError 10054), BrokenPipeError,
40
+ # timeouts — connection is dead or unusable. Report it
41
+ # exactly like a clean EOF so callers have ONE convention.
42
+ return None
relay_server.py ADDED
@@ -0,0 +1,106 @@
1
+ # Relay server: splices two peers' outbound connections into a pipe.
2
+ # Copies opaque bytes. No parsing, no decryption, no keys — the VM
3
+ # doesn't even have PyNaCl installed. Port 7001 (rendezvous is 7000).
4
+ # Known limitation (V2 hardening): a waiting entry whose peer never
5
+ # joins stays in memory until the relay restarts.
6
+
7
+ import socket
8
+ import threading
9
+ import json
10
+
11
+ from protocol import send_message, recv_message
12
+
13
+ HOST = "0.0.0.0"
14
+ PORT = 7001
15
+
16
+ waiting = {} # id -> (socket, target_they_are_waiting_for)
17
+ lock = threading.Lock()
18
+
19
+
20
+ def pump(src, dst):
21
+ """Copy raw bytes src -> dst until either side dies."""
22
+ try:
23
+ while True:
24
+ data = src.recv(4096)
25
+ if not data:
26
+ break
27
+ dst.sendall(data)
28
+ except OSError:
29
+ pass
30
+ finally:
31
+ try: src.close()
32
+ except OSError: pass
33
+ try: dst.close()
34
+ except OSError: pass
35
+
36
+
37
+ def handle(conn, addr):
38
+ spliced = False
39
+ registered = False
40
+ try:
41
+ data = recv_message(conn)
42
+ if data is None:
43
+ return
44
+ req = json.loads(data.decode())
45
+ if req.get("type") != "relay_join" or not req.get("id") or not req.get("target"):
46
+ send_message(conn, json.dumps({"status": "bad_request"}).encode())
47
+ return
48
+
49
+ my_id, target = req["id"], req["target"]
50
+
51
+ with lock:
52
+ rec = waiting.get(target)
53
+ if rec and rec[1] == my_id:
54
+ # mutual match: they were waiting for ME by name
55
+ waiting.pop(target)
56
+ peer_conn = rec[0]
57
+ else:
58
+ # no match yet — register and wait (keeps socket open)
59
+ waiting[my_id] = (conn, target)
60
+ registered = True
61
+ peer_conn = None
62
+
63
+ if peer_conn is not None:
64
+ spliced = True
65
+ # tell ONLY the second joiner; the first joiner's next bytes
66
+ # are the second's handshake key (transparent pipe from here)
67
+ send_message(conn, json.dumps({"status": "matched"}).encode())
68
+ print(f"[relay] spliced {target} <-> {my_id}")
69
+ threading.Thread(target=pump, args=(peer_conn, conn), daemon=True).start()
70
+ threading.Thread(target=pump, args=(conn, peer_conn), daemon=True).start()
71
+ else:
72
+ send_message(conn, json.dumps({"status": "waiting"}).encode())
73
+ print(f"[relay] {my_id} waiting for {target}")
74
+
75
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError):
76
+ try:
77
+ send_message(conn, json.dumps({"status": "bad_request"}).encode())
78
+ except OSError:
79
+ pass
80
+ finally:
81
+ # close ONLY sockets we never handed off. Waiting sockets belong
82
+ # to the registry; spliced sockets belong to the pump threads.
83
+ if not (spliced or registered):
84
+ try:
85
+ conn.close()
86
+ except OSError:
87
+ pass
88
+
89
+
90
+ def main():
91
+ server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
92
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
93
+ server.bind((HOST, PORT))
94
+ server.listen()
95
+ print(f"Relay server on {HOST}:{PORT}")
96
+
97
+ while True:
98
+ conn, addr = server.accept()
99
+ threading.Thread(target=handle, args=(conn, addr), daemon=True).start()
100
+
101
+
102
+ if __name__ == "__main__":
103
+ try:
104
+ main()
105
+ except KeyboardInterrupt:
106
+ print("\nRelay stopped.")
rendezvous_server.py ADDED
@@ -0,0 +1,99 @@
1
+ import socket
2
+ import threading
3
+ import json
4
+ import time
5
+ import hashlib
6
+ import base64
7
+
8
+ from protocol import send_message, recv_message
9
+
10
+ HOST = "0.0.0.0"
11
+ PORT = 7000
12
+ TTL = 90 # seconds — peers refresh every 30s
13
+
14
+ peers = {} # peer_id -> record
15
+ registry_lock = threading.Lock()
16
+
17
+
18
+ def prune_expired():
19
+ now = time.time()
20
+ for pid in [p for p, r in peers.items() if now - r["last_seen"] > TTL]:
21
+ del peers[pid]
22
+ print(f"[expiry] {pid} removed (stale > {TTL}s)")
23
+
24
+
25
+ def handle(conn, addr):
26
+ try:
27
+ data = recv_message(conn)
28
+ if data is None:
29
+ return
30
+
31
+ try:
32
+ request = json.loads(data.decode())
33
+ except (UnicodeDecodeError, json.JSONDecodeError):
34
+ send_message(conn, json.dumps({"status": "bad_request"}).encode())
35
+ return
36
+
37
+ rtype = request.get("type")
38
+
39
+ if rtype == "register":
40
+ peer_id = request.get("id")
41
+ listen_port = request.get("listen_port")
42
+ public_key_b64 = request.get("public_key")
43
+
44
+ if not peer_id or not listen_port or not public_key_b64:
45
+ send_message(conn, json.dumps({"status": "bad_request"}).encode())
46
+ return
47
+
48
+ fp = hashlib.sha256(base64.b64decode(public_key_b64)).hexdigest()
49
+ fingerprint = ":".join(fp[i:i + 4] for i in range(0, len(fp), 4))
50
+
51
+ with registry_lock:
52
+ peers[peer_id] = {
53
+ "ip": addr[0],
54
+ "listen_port": listen_port,
55
+ "observed_port": addr[1], # NEW: NAT's external port for THIS peer's listen port
56
+ "public_key": public_key_b64,
57
+ "fingerprint": fingerprint,
58
+ "last_seen": time.time(),
59
+ }
60
+ print(f"[register] {peer_id} {addr[0]}:{listen_port} fp={fingerprint[:19]}...")
61
+ send_message(conn, json.dumps({"status": "registered", "ttl": TTL}).encode())
62
+
63
+ elif rtype == "lookup":
64
+ with registry_lock:
65
+ prune_expired()
66
+ rec = peers.get(request.get("id"))
67
+ if rec:
68
+ response = {
69
+ "status": "found",
70
+ "ip": rec["ip"],
71
+ "port": rec["listen_port"],
72
+ "punch_port": rec["observed_port"], # NEW
73
+ "fingerprint": rec["fingerprint"],
74
+ }
75
+ else:
76
+ response = {"status": "not_found"}
77
+ send_message(conn, json.dumps(response).encode())
78
+
79
+ else:
80
+ send_message(conn, json.dumps({"status": "unknown_type"}).encode())
81
+
82
+ finally:
83
+ conn.close()
84
+
85
+
86
+ def main():
87
+ server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
88
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # restart without TIME_WAIT errors
89
+ server.bind((HOST, PORT))
90
+ server.listen()
91
+ print(f"Rendezvous server on {HOST}:{PORT} (TTL {TTL}s)")
92
+
93
+ while True:
94
+ conn, addr = server.accept()
95
+ threading.Thread(target=handle, args=(conn, addr), daemon=True).start()
96
+
97
+
98
+ if __name__ == "__main__":
99
+ main()
storage.py ADDED
@@ -0,0 +1,80 @@
1
+ import sqlite3
2
+
3
+ def init_db(filename):
4
+ connection = sqlite3.connect(filename)
5
+
6
+ connection.execute("""
7
+ CREATE TABLE IF NOT EXISTS messages(
8
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
9
+ fingerprint TEXT NOT NULL,
10
+ direction TEXT NOT NULL,
11
+ text TEXT NOT NULL,
12
+ timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
13
+ )
14
+ """)
15
+ connection.commit()
16
+ connection.close()
17
+
18
+ def save_message(filename, fingerprint, direction, text, box):
19
+ connection = sqlite3.connect(filename)
20
+
21
+ encrypted = box.encrypt(text.encode())
22
+
23
+ connection.execute(
24
+ """
25
+ INSERT INTO messages (fingerprint, direction, text)
26
+ VALUES(?, ?, ?)
27
+ """,
28
+ (fingerprint, direction, encrypted)
29
+ )
30
+ connection.commit()
31
+ connection.close()
32
+
33
+ def load_messages(filename, fingerprint, box):
34
+ connection = sqlite3.connect(filename)
35
+
36
+ cursor = connection.execute(
37
+ """
38
+ SELECT direction, text, timestamp
39
+ FROM messages
40
+ WHERE fingerprint = ?
41
+ ORDER BY id
42
+ """,
43
+ (fingerprint,)
44
+ )
45
+ rows = cursor.fetchall()
46
+ connection.close()
47
+ messages = []
48
+
49
+ for direction, encrypted_text, timestamp in rows:
50
+ text = box.decrypt(encrypted_text).decode()
51
+
52
+ messages.append(
53
+ (direction, text, timestamp)
54
+ )
55
+
56
+
57
+ return messages
58
+
59
+
60
+
61
+
62
+
63
+
64
+ # ! for test
65
+
66
+ # if __name__ == "__main__":
67
+ # db = "test_history.db"
68
+
69
+ # fingerprint = "A1B2:C3D4:E5F6"
70
+
71
+ # init_db(db)
72
+
73
+ # save_message(db, fingerprint, "sent", "Hello!")
74
+ # save_message(db, fingerprint, "received", "Hey!")
75
+ # save_message(db, fingerprint, "sent", "How are you?")
76
+
77
+ # messages = load_messages(db, fingerprint)
78
+
79
+ # for message in messages:
80
+ # print(message)