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.
- glypha-2.0.0.dist-info/METADATA +217 -0
- glypha-2.0.0.dist-info/RECORD +11 -0
- glypha-2.0.0.dist-info/WHEEL +5 -0
- glypha-2.0.0.dist-info/entry_points.txt +4 -0
- glypha-2.0.0.dist-info/top_level.txt +6 -0
- identity.py +31 -0
- peer.py +652 -0
- protocol.py +42 -0
- relay_server.py +106 -0
- rendezvous_server.py +99 -0
- storage.py +80 -0
|
@@ -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,,
|
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)
|