tortalk 1.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*.*.*"
7
+
8
+ jobs:
9
+ pypi-publish:
10
+ name: Build and publish to PyPI
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ id-token: write
14
+
15
+ steps:
16
+ - name: Checkout code
17
+ uses: actions/checkout@v4
18
+
19
+ - name: Set up Python
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.11"
23
+
24
+ - name: Install build tools
25
+ run: pip install --upgrade build
26
+
27
+ - name: Build sdist and wheel
28
+ run: python -m build
29
+
30
+ - name: Publish package distributions to PyPI
31
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .venv/
7
+ .env
8
+ .idea/
9
+ .vscode/
10
+ .DS_Store
tortalk-1.0.2/PKG-INFO ADDED
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.5
2
+ Name: tortalk
3
+ Version: 1.0.2
4
+ Summary: Secure, peer-to-peer terminal chat over Tor v3 onion services with E2EE
5
+ Author: Mohammad Saleh Abbasi
6
+ License: MIT
7
+ Keywords: chat,e2ee,onion,p2p,privacy,terminal,tor,tui
8
+ Classifier: Environment :: Console
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Communications :: Chat
13
+ Classifier: Topic :: Security :: Cryptography
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: cryptography>=42.0.0
16
+ Requires-Dist: pyperclip>=1.8.2
17
+ Requires-Dist: python-socks[asyncio]>=2.5.0
18
+ Requires-Dist: stem>=1.8.2
19
+ Requires-Dist: textual>=0.70.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # Tork
23
+
24
+ Secure, peer-to-peer terminal chat over Tor v3 onion services with End-to-End Encryption (E2EE).
25
+
26
+ ## Prerequisites
27
+
28
+ - Python >= 3.10
29
+ - Tor daemon running locally with SOCKS5 proxy enabled (default `127.0.0.1:9050`) and ControlPort enabled (default `127.0.0.1:9051`).
30
+
31
+ ## Quick Start
32
+
33
+ ```bash
34
+ pip install .
35
+ tork
36
+ ```
@@ -0,0 +1,15 @@
1
+ # Tork
2
+
3
+ Secure, peer-to-peer terminal chat over Tor v3 onion services with End-to-End Encryption (E2EE).
4
+
5
+ ## Prerequisites
6
+
7
+ - Python >= 3.10
8
+ - Tor daemon running locally with SOCKS5 proxy enabled (default `127.0.0.1:9050`) and ControlPort enabled (default `127.0.0.1:9051`).
9
+
10
+ ## Quick Start
11
+
12
+ ```bash
13
+ pip install .
14
+ tork
15
+ ```
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tortalk"
7
+ version = "1.0.2"
8
+ description = "Secure, peer-to-peer terminal chat over Tor v3 onion services with E2EE"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [
13
+ { name = "Mohammad Saleh Abbasi" }
14
+ ]
15
+ keywords = ["tor", "onion", "p2p", "chat", "terminal", "tui", "e2ee", "privacy"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Environment :: Console",
21
+ "Topic :: Communications :: Chat",
22
+ "Topic :: Security :: Cryptography",
23
+ ]
24
+ dependencies = [
25
+ "stem>=1.8.2",
26
+ "python-socks[asyncio]>=2.5.0",
27
+ "cryptography>=42.0.0",
28
+ "textual>=0.70.0",
29
+ "pyperclip>=1.8.2"
30
+ ]
31
+
32
+ [project.scripts]
33
+ tork = "tortalk.cli:main"
@@ -0,0 +1,16 @@
1
+ cffi==2.1.1
2
+ cryptography==50.0.2
3
+ linkify-it-py==2.2.0
4
+ markdown-it-py==4.2.0
5
+ mdit-py-plugins==0.6.1
6
+ mdurl==0.1.2
7
+ platformdirs==4.13.3
8
+ pycparser==3.11
9
+ Pygments==2.21.0
10
+ pyperclip==1.11.0
11
+ python-socks==3.1.1
12
+ rich==15.0.0
13
+ stem==1.8.2
14
+ textual==8.2.8
15
+ -e git+https://github.com/packetlost-ir/tork.git@a576064df91e5141161bc564d8135dce1efb69f3#egg=tork
16
+ typing_extensions==4.16.0
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from tork.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,283 @@
1
+ import asyncio
2
+ import argparse
3
+ import sys
4
+ import traceback
5
+ import pyperclip
6
+ from tork.config import DEFAULT_LOCAL_PORT, SOCKS_PORT, CONTROL_PORT, CONNECT_TIMEOUT, normalize_and_validate_onion, clean_hash
7
+ from tork.network.tor import get_controller, create_onion_service, destroy_onion_service
8
+ from tork.network.server import PeerServer
9
+ from tork.network.client import connect_peer
10
+ from tork.network.protocol import (
11
+ send_frame, receive_frame, perform_protocol_handshake,
12
+ encode_payload, decode_payload, OP_CHAT,
13
+ )
14
+ from tork.crypto import encrypt_message, decrypt_message
15
+ from tork.storage import StorageManager
16
+ from tork.ui.app import TorkApp
17
+
18
+ async def async_listen(local_port: int, control_port: int, timeout: float):
19
+ print(f"Connecting to Tor daemon (control port {control_port})...")
20
+ storage = StorageManager(config_dir=getattr(args_global, "config_dir", None), profile=getattr(args_global, "profile", None))
21
+ identity = storage.load_identity()
22
+
23
+ try:
24
+ controller = get_controller(control_port=control_port)
25
+ except Exception as e:
26
+ print(f"Failed to connect to Tor control port: {e}", file=sys.stderr)
27
+ return
28
+
29
+ try:
30
+ if identity:
31
+ print("Loading persistent identity...")
32
+ onion_hash, _, _ = create_onion_service(
33
+ controller,
34
+ local_port,
35
+ key_type=identity["key_type"],
36
+ key_content=identity["private_key"]
37
+ )
38
+ else:
39
+ print("Generating new persistent identity...")
40
+ onion_hash, key_type, private_key = create_onion_service(controller, local_port)
41
+ storage.save_identity(onion_hash, key_type, private_key)
42
+ except Exception as e:
43
+ print(f"Failed to configure ephemeral/persistent onion service: {e}", file=sys.stderr)
44
+ controller.close()
45
+ return
46
+
47
+ h = clean_hash(onion_hash)
48
+ print(f"Onion service created (hash): {h}")
49
+ print(f"Listening on 127.0.0.1:{local_port}...")
50
+
51
+ display_name = getattr(args_global, "profile", None) or f"peer-{h[:6]}"
52
+
53
+ async def handle_connection(reader, writer):
54
+ try:
55
+ shared_key, peer_hash, peer_name = await perform_protocol_handshake(
56
+ reader, writer, h, display_name, False, timeout=timeout
57
+ )
58
+ print("[i] Receiving encrypted frame...")
59
+ encrypted_payload = await receive_frame(reader)
60
+ print("[i] Decrypting incoming payload...")
61
+ plaintext_bytes = decrypt_message(shared_key, encrypted_payload)
62
+ msg_type, name, sender_onion, msg = decode_payload(plaintext_bytes)
63
+
64
+ # Nickname sync & Auto-save fallback
65
+ sender_onion = sender_onion or peer_hash
66
+ name = name or peer_name
67
+
68
+ if sender_onion and sender_onion != h:
69
+ alias = storage.find_alias_by_onion(sender_onion)
70
+ if alias:
71
+ if name and alias != name:
72
+ if storage.update_alias(alias, name):
73
+ print(f"[i] Nickname synced: {alias} -> {name}", flush=True)
74
+ else:
75
+ new_alias = name or f"peer-{sender_onion[:6]}"
76
+ storage.add_contact(new_alias, sender_onion)
77
+ print(f"[i] Discovered and saved contact: {new_alias} ({sender_onion[:8]}...)", flush=True)
78
+
79
+ who = name or (clean_hash(sender_onion)[:8] if sender_onion else "Peer")
80
+ print(f"[Incoming - Decrypted E2EE] [{who}] {msg}", flush=True)
81
+ except Exception as e:
82
+ print(f"[!] Error processing incoming message: {e}", file=sys.stderr, flush=True)
83
+ traceback.print_exc()
84
+
85
+ server = PeerServer("127.0.0.1", local_port, h, display_name, handle_connection)
86
+ await server.start()
87
+
88
+ stop_event = asyncio.Event()
89
+
90
+ try:
91
+ while not stop_event.is_set():
92
+ await asyncio.sleep(0.5)
93
+ except asyncio.CancelledError:
94
+ pass
95
+ finally:
96
+ print("\nShutting down server and removing onion service...")
97
+ await server.stop()
98
+ try:
99
+ destroy_onion_service(controller, h)
100
+ except Exception as e:
101
+ print(f"Error removing onion service: {e}", file=sys.stderr)
102
+ try:
103
+ controller.close()
104
+ except Exception:
105
+ pass
106
+ print("Done.")
107
+
108
+ async def async_send(target_arg: str, message: str, socks_port: int, timeout: float, config_dir=None, profile=None):
109
+ storage = StorageManager(config_dir=config_dir, profile=profile)
110
+ resolved_onion = storage.get_contact(target_arg) or target_arg
111
+
112
+ is_valid, normalized_addr, err_msg = normalize_and_validate_onion(resolved_onion)
113
+ if not is_valid:
114
+ print(f"Error: Invalid onion address or unknown contact '{target_arg}': {err_msg}", file=sys.stderr)
115
+ sys.exit(1)
116
+
117
+ h = clean_hash(normalized_addr)
118
+ print(f"Connecting to {h} via Tor SOCKS5 (port {socks_port})...")
119
+ try:
120
+ reader, writer = await connect_peer(normalized_addr, socks_port=socks_port, timeout=timeout)
121
+ except Exception as e:
122
+ print(f"Error connecting to peer: {e}", file=sys.stderr)
123
+ sys.exit(1)
124
+
125
+ try:
126
+ identity = storage.load_identity()
127
+ self_hash = clean_hash(identity["service_id"]) if identity else ""
128
+ display_name = profile or (f"peer-{self_hash[:6]}" if self_hash else "")
129
+
130
+ shared_key, peer_hash, peer_name = await perform_protocol_handshake(
131
+ reader, writer, self_hash, display_name, True, timeout=timeout
132
+ )
133
+ print("[i] Encrypting message with ChaCha20-Poly1305...")
134
+ payload = encode_payload(display_name, self_hash, message)
135
+ # Prepend OP_CHAT opcode for compatibility with PeerSession
136
+ encrypted_payload = encrypt_message(shared_key, bytes([OP_CHAT]) + payload)
137
+ print("[i] Sending encrypted frame...")
138
+ await send_frame(writer, encrypted_payload)
139
+ print("Message encrypted with ChaCha20-Poly1305 and delivered securely.")
140
+ except Exception as e:
141
+ print(f"Error sending message: {e}", file=sys.stderr)
142
+ traceback.print_exc()
143
+ sys.exit(1)
144
+ finally:
145
+ writer.close()
146
+ try:
147
+ await writer.wait_closed()
148
+ except Exception:
149
+ pass
150
+
151
+ def handle_id(copy_to_clip: bool, control_port: int, config_dir=None, profile=None):
152
+ storage = StorageManager(config_dir=config_dir, profile=profile)
153
+ identity = storage.load_identity()
154
+ if not identity:
155
+ print("No identity found. Generating identity...")
156
+ try:
157
+ controller = get_controller(control_port=control_port)
158
+ onion_hash, key_type, private_key = create_onion_service(controller, DEFAULT_LOCAL_PORT, await_publication=False)
159
+ storage.save_identity(onion_hash, key_type, private_key)
160
+ controller.close()
161
+ identity = storage.load_identity()
162
+ except Exception as e:
163
+ print(f"Error generating identity via Tor daemon: {e}", file=sys.stderr)
164
+ sys.exit(1)
165
+
166
+ h = clean_hash(identity["service_id"])
167
+ print(f"Your persistent Tor Onion Address (hash): {h}")
168
+ if copy_to_clip:
169
+ try:
170
+ pyperclip.copy(h)
171
+ print("Copied 56-character hash to clipboard.")
172
+ except Exception as e:
173
+ print(f"Could not copy to clipboard: {e}", file=sys.stderr)
174
+
175
+ def handle_contact(args):
176
+ storage = StorageManager(config_dir=args.config_dir, profile=args.profile)
177
+ subaction = args.contact_action
178
+
179
+ if subaction == "add":
180
+ success, res = storage.add_contact(args.alias, args.onion)
181
+ if success:
182
+ h = clean_hash(res)
183
+ print(f"Contact '{args.alias}' added: {h}")
184
+ else:
185
+ print(f"Error adding contact: {res}", file=sys.stderr)
186
+ sys.exit(1)
187
+ elif subaction == "list":
188
+ contacts = storage.list_contacts()
189
+ if not contacts:
190
+ print("No contacts saved.")
191
+ else:
192
+ print("Saved Contacts:")
193
+ for alias, data in contacts.items():
194
+ print(f" {alias} -> {clean_hash(data['onion'])}")
195
+ elif subaction == "remove":
196
+ removed = storage.remove_contact(args.alias)
197
+ if removed:
198
+ print(f"Contact '{args.alias}' removed.")
199
+ else:
200
+ print(f"Contact '{args.alias}' not found.", file=sys.stderr)
201
+ sys.exit(1)
202
+ else:
203
+ print("Invalid contact action.", file=sys.stderr)
204
+ sys.exit(1)
205
+
206
+ args_global = None
207
+
208
+ def main():
209
+ global args_global
210
+ parser = argparse.ArgumentParser(description="Tork P2P Terminal Chat over Tor")
211
+
212
+ # Global arguments
213
+ parser.add_argument("--config-dir", type=str, default=None, help="Custom configuration directory")
214
+ parser.add_argument("--profile", type=str, default=None, help="Profile name shorthand")
215
+ parser.add_argument("-p", "--local-port", type=int, default=DEFAULT_LOCAL_PORT, help="Local TCP listen port")
216
+ parser.add_argument("--socks-port", type=int, default=SOCKS_PORT, help="Tor SOCKS5 proxy port")
217
+ parser.add_argument("--control-port", type=int, default=CONTROL_PORT, help="Tor ControlPort")
218
+ parser.add_argument("--timeout", type=float, default=CONNECT_TIMEOUT, help="Connection / handshake timeout")
219
+
220
+ subparsers = parser.add_subparsers(dest="command", required=False)
221
+
222
+ listen_parser = subparsers.add_parser("listen", help="Listen for incoming peer connections")
223
+
224
+ send_parser = subparsers.add_parser("send", help="Send a message to a peer onion address or contact alias")
225
+ send_parser.add_argument("onion_address", type=str, help="Target peer onion address or contact alias")
226
+ send_parser.add_argument("message", type=str, help="Message text to send")
227
+
228
+ id_parser = subparsers.add_parser("id", help="Display local persistent 56-char onion hash")
229
+ id_parser.add_argument("--copy", action="store_true", help="Copy hash to clipboard")
230
+
231
+ chat_parser = subparsers.add_parser("chat", help="Launch Textual TUI chat interface")
232
+ chat_parser.add_argument("target", nargs="?", default=None, help="Optional initial target contact or onion hash")
233
+
234
+ contact_parser = subparsers.add_parser("contact", help="Manage saved contacts")
235
+ contact_subparsers = contact_parser.add_subparsers(dest="contact_action", required=True)
236
+
237
+ add_p = contact_subparsers.add_parser("add", help="Add a new contact")
238
+ add_p.add_argument("alias", type=str, help="Contact alias")
239
+ add_p.add_argument("onion", type=str, help="Peer onion address")
240
+
241
+ contact_subparsers.add_parser("list", help="List all contacts")
242
+
243
+ remove_p = contact_subparsers.add_parser("remove", help="Remove a contact")
244
+ remove_p.add_argument("alias", type=str, help="Contact alias to remove")
245
+
246
+ args, unknown = parser.parse_known_args()
247
+ args_global = args
248
+
249
+ command = args.command if args.command else "chat"
250
+ target = getattr(args, "target", None)
251
+
252
+ if command == "listen":
253
+ try:
254
+ asyncio.run(async_listen(args.local_port, args.control_port, args.timeout))
255
+ except KeyboardInterrupt:
256
+ pass
257
+ elif command == "send":
258
+ try:
259
+ asyncio.run(async_send(args.onion_address, args.message, args.socks_port, args.timeout, config_dir=args.config_dir, profile=args.profile))
260
+ except KeyboardInterrupt:
261
+ pass
262
+ elif command == "id":
263
+ handle_id(args.copy, args.control_port, config_dir=args.config_dir, profile=args.profile)
264
+ elif command == "contact":
265
+ handle_contact(args)
266
+ elif command == "chat":
267
+ storage = StorageManager(config_dir=args.config_dir, profile=args.profile)
268
+ resolved_target = None
269
+ if target:
270
+ resolved_target = storage.get_contact(target) or target
271
+ app = TorkApp(
272
+ initial_target=resolved_target,
273
+ config_dir=args.config_dir,
274
+ profile=args.profile,
275
+ local_port=args.local_port,
276
+ socks_port=args.socks_port,
277
+ control_port=args.control_port,
278
+ timeout=args.timeout
279
+ )
280
+ app.run()
281
+
282
+ if __name__ == "__main__":
283
+ main()
@@ -0,0 +1,60 @@
1
+ import re
2
+ from pathlib import Path
3
+
4
+ SOCKS_HOST: str = "127.0.0.1"
5
+ SOCKS_PORT: int = 9050
6
+ CONTROL_PORT: int = 9051
7
+ DEFAULT_LOCAL_PORT: int = 5666
8
+ CONNECT_TIMEOUT: float = 120.0
9
+
10
+ ONION_REGEX = re.compile(r"^[a-z2-7]{56}\.onion$")
11
+
12
+ def normalize_and_validate_onion(raw_input: str) -> tuple[bool, str, str]:
13
+ if not isinstance(raw_input, str):
14
+ return False, "", "Input must be a string."
15
+
16
+ val = raw_input.strip().lower()
17
+ for proto in ("http://", "https://", "socks5://", "socks://"):
18
+ if val.startswith(proto):
19
+ val = val[len(proto):]
20
+ break
21
+
22
+ val = val.split("/")[0]
23
+
24
+ if len(val) == 56 and re.match(r"^[a-z2-7]{56}$", val):
25
+ val = f"{val}.onion"
26
+
27
+ if ONION_REGEX.match(val):
28
+ return True, val, ""
29
+
30
+ return False, raw_input, "Invalid .onion address format."
31
+
32
+ def clean_hash(onion: object) -> str:
33
+ if not onion:
34
+ return ""
35
+
36
+ # Handle schema variance (list of dicts, flat dicts, or strings)
37
+ if isinstance(onion, dict):
38
+ onion = onion.get("onion", "")
39
+ elif isinstance(onion, list) and len(onion) > 0:
40
+ first = onion[0]
41
+ onion = first.get("onion", "") if isinstance(first, dict) else str(first)
42
+ elif not isinstance(onion, str):
43
+ onion = str(onion)
44
+
45
+ val = onion.strip().lower()
46
+ for proto in ("http://", "https://", "socks5://", "socks://"):
47
+ if val.startswith(proto):
48
+ val = val[len(proto):]
49
+ break
50
+ val = val.split("/")[0]
51
+ if val.endswith(".onion"):
52
+ val = val[:-6]
53
+ return val
54
+
55
+ def resolve_config_dir(config_dir: str | Path | None = None, profile: str | None = None) -> Path:
56
+ if config_dir:
57
+ return Path(config_dir).expanduser().resolve()
58
+ if profile:
59
+ return (Path.home() / ".config" / "tork" / "profiles" / profile).resolve()
60
+ return (Path.home() / ".config" / "tork").resolve()
@@ -0,0 +1,36 @@
1
+ import os
2
+ import traceback
3
+ from cryptography.hazmat.primitives.asymmetric import x25519
4
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
5
+ from cryptography.hazmat.primitives import hashes
6
+ from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
7
+
8
+ def generate_ephemeral_keypair() -> tuple[x25519.X25519PrivateKey, bytes]:
9
+ private_key = x25519.X25519PrivateKey.generate()
10
+ public_bytes = private_key.public_key().public_bytes_raw()
11
+ return private_key, public_bytes
12
+
13
+ def derive_shared_key(private_key: x25519.X25519PrivateKey, peer_public_bytes: bytes) -> bytes:
14
+ peer_pub_key = x25519.X25519PublicKey.from_public_bytes(peer_public_bytes)
15
+ shared_secret = private_key.exchange(peer_pub_key)
16
+ derived_key = HKDF(
17
+ algorithm=hashes.SHA256(),
18
+ length=32,
19
+ salt=b"tork-e2ee-salt-v1",
20
+ info=b"tork-handshake",
21
+ ).derive(shared_secret)
22
+ return derived_key
23
+
24
+ def encrypt_message(key: bytes, plaintext: bytes) -> bytes:
25
+ nonce = os.urandom(12)
26
+ cipher = ChaCha20Poly1305(key)
27
+ ciphertext = cipher.encrypt(nonce, plaintext, None)
28
+ return nonce + ciphertext
29
+
30
+ def decrypt_message(key: bytes, payload: bytes) -> bytes:
31
+ if len(payload) < 12:
32
+ raise ValueError("Payload too short for nonce and ciphertext.")
33
+ nonce = payload[:12]
34
+ ciphertext = payload[12:]
35
+ cipher = ChaCha20Poly1305(key)
36
+ return cipher.decrypt(nonce, ciphertext, None)
File without changes
@@ -0,0 +1,18 @@
1
+ import asyncio
2
+ from python_socks import ProxyType, ProxyError
3
+ from python_socks.async_.asyncio import Proxy
4
+ from tork.config import normalize_and_validate_onion, SOCKS_HOST, SOCKS_PORT, CONNECT_TIMEOUT
5
+
6
+ async def connect_peer(target_onion, socks_host=SOCKS_HOST, socks_port=SOCKS_PORT, timeout=CONNECT_TIMEOUT):
7
+ is_valid, normalized_addr, err = normalize_and_validate_onion(target_onion)
8
+ if not is_valid: raise ValueError(f"Invalid address: {err}")
9
+
10
+ proxy = Proxy.create(proxy_type=ProxyType.SOCKS5, host=socks_host, port=socks_port, rdns=True)
11
+
12
+ for attempt in range(6):
13
+ try:
14
+ sock = await asyncio.wait_for(proxy.connect(dest_host=normalized_addr, dest_port=80), timeout=30)
15
+ return await asyncio.open_connection(sock=sock)
16
+ except (ProxyError, OSError, asyncio.TimeoutError) as e:
17
+ if attempt == 5: raise ConnectionError(f"Target unreachable: {e}")
18
+ await asyncio.sleep(15)
@@ -0,0 +1,149 @@
1
+ import asyncio
2
+ import struct
3
+ import json
4
+ import time
5
+ from tork.crypto import generate_ephemeral_keypair, derive_shared_key, encrypt_message, decrypt_message
6
+
7
+ # Opcodes
8
+ OP_HANDSHAKE_INIT = 1
9
+ OP_HANDSHAKE_ACK = 2
10
+ OP_CHAT = 3
11
+ OP_PING = 4
12
+ OP_PONG = 5
13
+
14
+ async def send_frame(writer: asyncio.StreamWriter, data: bytes) -> None:
15
+ writer.write(struct.pack("!I", len(data)) + data)
16
+ await writer.drain()
17
+
18
+ async def receive_frame(reader: asyncio.StreamReader) -> bytes:
19
+ header = await reader.readexactly(4)
20
+ length = struct.unpack("!I", header)[0]
21
+ return await reader.readexactly(length)
22
+
23
+ class PeerSession:
24
+ def __init__(self, reader, writer, shared_key, peer_hash, peer_name, is_initiator, app_callback):
25
+ self.reader = reader
26
+ self.writer = writer
27
+ self.shared_key = shared_key
28
+ self.peer_hash = peer_hash
29
+ self.peer_name = peer_name
30
+ self.is_initiator = is_initiator
31
+ self.app_callback = app_callback
32
+ self.last_seen = time.time()
33
+ self.active = True
34
+ self._read_task = None
35
+ self._keepalive_task = None
36
+
37
+ def start(self):
38
+ self._read_task = asyncio.create_task(self._reader_loop())
39
+ self._keepalive_task = asyncio.create_task(self._keepalive_loop())
40
+
41
+ async def send_op(self, opcode: int, payload_dict: dict = None):
42
+ if not self.active: return
43
+ data = json.dumps(payload_dict or {}).encode("utf-8")
44
+ encrypted = encrypt_message(self.shared_key, bytes([opcode]) + data)
45
+ try:
46
+ await send_frame(self.writer, encrypted)
47
+ except Exception:
48
+ await self.close()
49
+
50
+ async def _reader_loop(self):
51
+ try:
52
+ while self.active:
53
+ encrypted = await receive_frame(self.reader)
54
+ decrypted = decrypt_message(self.shared_key, encrypted)
55
+ opcode = decrypted[0]
56
+ payload = json.loads(decrypted[1:].decode("utf-8"))
57
+ self.last_seen = time.time()
58
+
59
+ if opcode == OP_CHAT:
60
+ await self.app_callback("chat", self.peer_hash, payload)
61
+ elif opcode == OP_PING:
62
+ await self.send_op(OP_PONG)
63
+ elif opcode == OP_PONG:
64
+ pass
65
+ except Exception:
66
+ pass
67
+ finally:
68
+ await self.close()
69
+
70
+ async def _keepalive_loop(self):
71
+ try:
72
+ while self.active:
73
+ await asyncio.sleep(20)
74
+ if time.time() - self.last_seen > 60:
75
+ break
76
+ await self.send_op(OP_PING)
77
+ except Exception:
78
+ pass
79
+ finally:
80
+ await self.close()
81
+
82
+ async def close(self):
83
+ if not self.active: return
84
+ self.active = False
85
+ if self._read_task: self._read_task.cancel()
86
+ if self._keepalive_task: self._keepalive_task.cancel()
87
+ try:
88
+ self.writer.close()
89
+ # Non-blocking wait_closed to avoid hang on exit
90
+ await asyncio.wait_for(self.writer.wait_closed(), timeout=0.5)
91
+ except Exception: pass
92
+ await self.app_callback("closed", self.peer_hash, None)
93
+
94
+ async def perform_protocol_handshake(reader, writer, local_hash, local_name, is_initiator, timeout=30):
95
+ priv_key, pub_bytes = generate_ephemeral_keypair()
96
+ if is_initiator:
97
+ await send_frame(writer, pub_bytes)
98
+ peer_pub = await asyncio.wait_for(receive_frame(reader), timeout)
99
+ else:
100
+ peer_pub = await asyncio.wait_for(receive_frame(reader), timeout)
101
+ await send_frame(writer, pub_bytes)
102
+
103
+ shared_key = derive_shared_key(priv_key, peer_pub)
104
+
105
+ # Secure Identity Exchange
106
+ if is_initiator:
107
+ init_data = json.dumps({"o": local_hash, "n": local_name}).encode("utf-8")
108
+ await send_frame(writer, encrypt_message(shared_key, bytes([OP_HANDSHAKE_INIT]) + init_data))
109
+ resp = decrypt_message(shared_key, await asyncio.wait_for(receive_frame(reader), timeout))
110
+ if resp[0] != OP_HANDSHAKE_ACK: raise ValueError("Invalid ack")
111
+ peer_info = json.loads(resp[1:].decode("utf-8"))
112
+ else:
113
+ req = decrypt_message(shared_key, await asyncio.wait_for(receive_frame(reader), timeout))
114
+ if req[0] != OP_HANDSHAKE_INIT: raise ValueError("Invalid init")
115
+ peer_info = json.loads(req[1:].decode("utf-8"))
116
+ ack_data = json.dumps({"o": local_hash, "n": local_name}).encode("utf-8")
117
+ await send_frame(writer, encrypt_message(shared_key, bytes([OP_HANDSHAKE_ACK]) + ack_data))
118
+
119
+ return shared_key, peer_info["o"], peer_info["n"]
120
+
121
+ def encode_payload(display_name: str, sender_onion: str, message: str, msg_type: str = "chat") -> bytes:
122
+ envelope = {
123
+ "t": msg_type,
124
+ "n": display_name or "",
125
+ "o": (sender_onion or "").removesuffix(".onion"),
126
+ "m": message,
127
+ }
128
+ # For cli compatibility with opcode-based protocol, we prepend OP_CHAT if it's a chat message
129
+ # But wait, cli listen/send currently don't use PeerSession.
130
+ # To keep it simple, let's make decode_payload handle both raw envelope and opcode+envelope.
131
+ return json.dumps(envelope, ensure_ascii=False).encode("utf-8")
132
+
133
+ def decode_payload(data: bytes) -> tuple[str, str, str, str]:
134
+ # Returns (type, display_name, sender_onion_hash, message).
135
+ # Handle optional opcode prefix (1 byte)
136
+ offset = 0
137
+ if len(data) > 0 and data[0] in (OP_HANDSHAKE_INIT, OP_HANDSHAKE_ACK, OP_CHAT, OP_PING, OP_PONG):
138
+ offset = 1
139
+
140
+ try:
141
+ obj = json.loads(data[offset:].decode("utf-8"))
142
+ if isinstance(obj, dict):
143
+ # If it's a PeerSession style chat packet {"m": ..., "ts": ...}
144
+ if "m" in obj and "t" not in obj:
145
+ return "chat", "", "", obj.get("m", "")
146
+ return str(obj.get("t", "chat")), str(obj.get("n", "")), str(obj.get("o", "")), str(obj.get("m", ""))
147
+ except (UnicodeDecodeError, json.JSONDecodeError):
148
+ pass
149
+ return "chat", "", "", data.decode("utf-8", errors="replace")
@@ -0,0 +1,31 @@
1
+ import asyncio
2
+ import traceback
3
+ from tork.network.protocol import perform_protocol_handshake
4
+
5
+ class PeerServer:
6
+ def __init__(self, host, port, local_hash, local_name, session_callback):
7
+ self.host = host
8
+ self.port = port
9
+ self.local_hash = local_hash
10
+ self.local_name = local_name
11
+ self.session_callback = session_callback
12
+ self.server = None
13
+
14
+ async def _handle_connection(self, reader, writer):
15
+ try:
16
+ shared_key, peer_hash, peer_name = await perform_protocol_handshake(
17
+ reader, writer, self.local_hash, self.local_name, False
18
+ )
19
+ await self.session_callback(reader, writer, shared_key, peer_hash, peer_name, False)
20
+ except Exception as e:
21
+ writer.close()
22
+ try: await writer.wait_closed()
23
+ except Exception: pass
24
+
25
+ async def start(self):
26
+ self.server = await asyncio.start_server(self._handle_connection, self.host, self.port)
27
+
28
+ async def stop(self):
29
+ if self.server:
30
+ self.server.close()
31
+ await self.server.wait_closed()
@@ -0,0 +1,35 @@
1
+ from stem.control import Controller
2
+ from tork.config import CONTROL_PORT
3
+
4
+ def get_controller(control_port: int = CONTROL_PORT) -> Controller:
5
+ controller = Controller.from_port(port=control_port)
6
+ controller.authenticate()
7
+ return controller
8
+
9
+ def create_onion_service(
10
+ controller: Controller,
11
+ local_port: int,
12
+ key_type: str = "NEW",
13
+ key_content: str = "ED25519-V3",
14
+ await_publication: bool = True
15
+ ) -> tuple[str, str, str]:
16
+ response = controller.create_ephemeral_hidden_service(
17
+ {80: local_port},
18
+ await_publication=await_publication,
19
+ key_type=key_type,
20
+ key_content=key_content,
21
+ discard_key=False
22
+ )
23
+ service_id = response.service_id
24
+ if service_id.endswith(".onion"):
25
+ service_id = service_id[:-6]
26
+ return service_id, response.private_key_type, response.private_key
27
+
28
+ def destroy_onion_service(controller: Controller, onion_address: str) -> None:
29
+ service_id = onion_address
30
+ if service_id.endswith(".onion"):
31
+ service_id = service_id[:-6]
32
+ try:
33
+ controller.remove_ephemeral_hidden_service(service_id)
34
+ except Exception:
35
+ pass
@@ -0,0 +1,131 @@
1
+ import os
2
+ import json
3
+ from pathlib import Path
4
+ from tork.config import normalize_and_validate_onion, resolve_config_dir
5
+
6
+ class StorageManager:
7
+ def __init__(self, config_dir: str | Path | None = None, profile: str | None = None):
8
+ self.config_dir = resolve_config_dir(config_dir, profile)
9
+ self.config_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
10
+ self.identity_file = self.config_dir / "identity.json"
11
+ self.contacts_file = self.config_dir / "contacts.json"
12
+
13
+ def load_identity(self) -> dict | None:
14
+ if self.identity_file.exists():
15
+ try:
16
+ with open(self.identity_file, "r", encoding="utf-8") as f:
17
+ return json.load(f)
18
+ except Exception:
19
+ return None
20
+ return None
21
+
22
+ def save_identity(self, service_id: str, key_type: str, private_key: str) -> None:
23
+ data = {
24
+ "service_id": service_id,
25
+ "key_type": key_type,
26
+ "private_key": private_key
27
+ }
28
+ with open(self.identity_file, "w", encoding="utf-8") as f:
29
+ json.dump(data, f, indent=2)
30
+ os.chmod(self.identity_file, 0o600)
31
+
32
+ def _load_contacts_raw(self) -> dict:
33
+ if not self.contacts_file.exists():
34
+ return {}
35
+ try:
36
+ with open(self.contacts_file, "r", encoding="utf-8") as f:
37
+ data = json.load(f)
38
+ if isinstance(data, list):
39
+ # Flatten list of objects to dict indexed by alias/nickname
40
+ return {item.get("nickname") or item.get("alias") or f"peer-{str(item.get('onion'))[:6]}": item for item in data if isinstance(item, dict)}
41
+ return data if isinstance(data, dict) else {}
42
+ except Exception:
43
+ return {}
44
+
45
+ def _save_contacts_raw(self, contacts: dict) -> None:
46
+ with open(self.contacts_file, "w", encoding="utf-8") as f:
47
+ json.dump(contacts, f, indent=2)
48
+ os.chmod(self.contacts_file, 0o600)
49
+
50
+ def list_contacts(self) -> dict[str, dict]:
51
+ """Returns normalized contacts: { alias: { nickname, onion, last_seen } }"""
52
+ raw = self._load_contacts_raw()
53
+ normalized = {}
54
+ for key, val in raw.items():
55
+ if isinstance(val, str):
56
+ normalized[key] = {"nickname": key, "onion": val, "last_seen": None}
57
+ elif isinstance(val, list):
58
+ # Handle nested list quirk mentioned in issue
59
+ onion_str = val[0].get("onion", "") if val and isinstance(val[0], dict) else ""
60
+ normalized[key] = {"nickname": key, "onion": onion_str, "last_seen": None}
61
+ elif isinstance(val, dict):
62
+ normalized[key] = {
63
+ "nickname": val.get("nickname") or val.get("alias") or key,
64
+ "onion": val.get("onion", ""),
65
+ "last_seen": val.get("last_seen")
66
+ }
67
+ return normalized
68
+
69
+ def add_contact(self, alias: str, onion: str) -> tuple[bool, str]:
70
+ is_valid, normalized_onion, err = normalize_and_validate_onion(onion)
71
+ if not is_valid:
72
+ return False, f"Invalid onion address: {err}"
73
+
74
+ target_hash = normalized_onion.removesuffix(".onion")
75
+ contacts = self._load_contacts_raw()
76
+
77
+ # Deduplicate by onion hash
78
+ existing_alias = self.find_alias_by_onion(target_hash)
79
+ if existing_alias:
80
+ if existing_alias != alias:
81
+ # Rename the contact key to the new alias
82
+ data = contacts.pop(existing_alias)
83
+ if isinstance(data, dict):
84
+ data["nickname"] = alias
85
+ else:
86
+ data = {"nickname": alias, "onion": target_hash}
87
+ contacts[alias] = data
88
+ else:
89
+ if isinstance(contacts[alias], dict):
90
+ contacts[alias]["nickname"] = alias
91
+ self._save_contacts_raw(contacts)
92
+ return True, target_hash
93
+
94
+ contacts[alias] = {
95
+ "nickname": alias,
96
+ "onion": target_hash,
97
+ "last_seen": None
98
+ }
99
+ self._save_contacts_raw(contacts)
100
+ return True, target_hash
101
+
102
+ def get_contact(self, alias: str) -> str | None:
103
+ """Returns clean 56-char onion string for an alias."""
104
+ contacts = self.list_contacts()
105
+ data = contacts.get(alias)
106
+ if data:
107
+ return data.get("onion")
108
+ return None
109
+
110
+ def find_alias_by_onion(self, onion: str) -> str | None:
111
+ target = onion.removesuffix(".onion")
112
+ for alias, data in self.list_contacts().items():
113
+ if data.get("onion") == target:
114
+ return alias
115
+ return None
116
+
117
+ def update_alias(self, old_alias: str, new_alias: str) -> bool:
118
+ new_alias = (new_alias or "").strip()
119
+ if not new_alias or new_alias == old_alias:
120
+ return False
121
+
122
+ contacts = self._load_contacts_raw()
123
+ if old_alias not in contacts or new_alias in contacts:
124
+ return False
125
+
126
+ data = contacts.pop(old_alias)
127
+ if isinstance(data, dict):
128
+ data["nickname"] = new_alias
129
+ contacts[new_alias] = data
130
+ self._save_contacts_raw(contacts)
131
+ return True
@@ -0,0 +1,311 @@
1
+ import asyncio
2
+ import time
3
+ from textual.app import App, ComposeResult
4
+ from textual.widgets import Header, Footer, Static, Input, RichLog, ListItem, ListView, Label
5
+ from textual.containers import Horizontal, Vertical
6
+ from textual.message import Message
7
+ from textual import work
8
+ from rich.panel import Panel
9
+
10
+ from tork.config import DEFAULT_LOCAL_PORT, SOCKS_PORT, CONTROL_PORT, CONNECT_TIMEOUT, clean_hash, normalize_and_validate_onion
11
+ from tork.storage import StorageManager
12
+ from tork.network.tor import get_controller, create_onion_service, destroy_onion_service
13
+ from tork.network.server import PeerServer
14
+ from tork.network.client import connect_peer
15
+ from tork.network.protocol import PeerSession, OP_CHAT, perform_protocol_handshake
16
+ from tork.ui.commands import TorkCommandProvider
17
+ from tork.ui.modals import AddContactModal
18
+
19
+ class PeerEvent(Message):
20
+ def __init__(self, type, peer_hash, data=None):
21
+ self.type = type
22
+ self.peer_hash = peer_hash
23
+ self.data = data
24
+ super().__init__()
25
+
26
+ class TorkApp(App):
27
+ CSS = """
28
+ Screen { background: $surface; color: $text; }
29
+ #main { layout: horizontal; }
30
+ #sidebar { width: 35; background: $boost; border-right: solid $primary-background; }
31
+ #chat { layout: vertical; }
32
+ #chat-header { height: 3; background: $panel; padding: 1; color: $accent; }
33
+ #chat-log { height: 1fr; background: $surface; }
34
+ #input-box { height: 3; }
35
+ .online { color: $success; }
36
+ .offline { color: $text-muted; }
37
+ .unread { color: $error; text-style: bold; }
38
+ """
39
+
40
+ COMMANDS = App.COMMANDS | {TorkCommandProvider}
41
+ BINDINGS = [("q", "quit", "Quit"), ("a", "prompt_add_contact", "Add Contact")]
42
+
43
+ def __init__(self, initial_target=None, config_dir=None, profile=None, local_port=DEFAULT_LOCAL_PORT, socks_port=SOCKS_PORT, control_port=CONTROL_PORT, timeout=CONNECT_TIMEOUT):
44
+ super().__init__()
45
+ self.storage = StorageManager(config_dir=config_dir, profile=profile)
46
+ self.local_port, self.socks_port, self.control_port, self.timeout = local_port, socks_port, control_port, timeout
47
+ self.profile = profile
48
+ self.sessions = {} # hash -> PeerSession
49
+ self.chat_buffers = {} # hash -> list
50
+ self.unread_counts = {} # hash -> int
51
+ self.active_peer_hash = None
52
+ self.local_onion_hash = ""
53
+ self.display_name = profile or ""
54
+
55
+ def action_copy_onion_id(self) -> None:
56
+ if self.local_onion_hash:
57
+ try:
58
+ import pyperclip
59
+ pyperclip.copy(self.local_onion_hash)
60
+ self.notify(f"Onion ID copied to clipboard: {self.local_onion_hash[:8]}...", title="Tork Identity")
61
+ except Exception as e:
62
+ self.notify(f"Copy failed: {e}", severity="error")
63
+
64
+ def action_prompt_add_contact(self) -> None:
65
+ async def _add_contact():
66
+ h = await self.push_screen_wait(AddContactModal())
67
+ if not h or h == self.local_onion_hash: return
68
+
69
+ alias = self.storage.find_alias_by_onion(h) or f"peer-{h[:6]}"
70
+ self.storage.add_contact(alias, h)
71
+ self.refresh_contacts()
72
+
73
+ # Switch focus to the contact
74
+ self.active_peer_hash = h
75
+ self.unread_counts[h] = 0
76
+ self.refresh_contacts()
77
+
78
+ # Select in ListView
79
+ try:
80
+ lv = self.query_one("#contacts-list", ListView)
81
+ for i, item in enumerate(lv.children):
82
+ if isinstance(item, ListItem) and item.name == h:
83
+ lv.index = i
84
+ break
85
+ except Exception: pass
86
+
87
+ if h not in self.sessions:
88
+ self.run_worker(self._connect_to_peer(h, alias))
89
+
90
+ self.run_worker(_add_contact())
91
+
92
+ async def _connect_to_peer(self, h: str, alias: str):
93
+ self.query_one("#chat-header", Static).update(f"Connecting to {alias} via Tor...")
94
+ try:
95
+ r, w = await connect_peer(h, socks_port=self.socks_port)
96
+ key, ph, pn = await perform_protocol_handshake(r, w, self.local_onion_hash, self.display_name, True)
97
+ await self.register_session(r, w, key, ph, pn, True)
98
+ if h == self.active_peer_hash:
99
+ self.query_one("#chat-header", Static).update(f"Chat: {alias} [E2EE Secured]")
100
+ except Exception as e:
101
+ if h == self.active_peer_hash:
102
+ self.query_one("#chat-header", Static).update(f"Chat: {alias} (Offline)")
103
+ self.notify(f"Failed to connect to {alias}: {e}", severity="error")
104
+
105
+ def compose(self) -> ComposeResult:
106
+ yield Header()
107
+ with Horizontal(id="main"):
108
+ with Vertical(id="sidebar"):
109
+ yield Label(" CONTACTS", classes="section-title")
110
+ yield ListView(id="contacts-list")
111
+ with Vertical(id="chat"):
112
+ yield Static("Select a contact", id="chat-header")
113
+ yield RichLog(id="chat-log", markup=True)
114
+ yield Input(placeholder="Type message...", id="msg-input")
115
+ yield Footer()
116
+
117
+ async def on_mount(self):
118
+ await self.start_services()
119
+ self.refresh_contacts()
120
+
121
+ async def start_services(self):
122
+ try:
123
+ self.controller = await asyncio.to_thread(get_controller, self.control_port)
124
+ id = self.storage.load_identity()
125
+ if id:
126
+ h, _, _ = await asyncio.to_thread(
127
+ create_onion_service,
128
+ self.controller,
129
+ self.local_port,
130
+ id["key_type"],
131
+ id["private_key"]
132
+ )
133
+ else:
134
+ h, t, k = await asyncio.to_thread(create_onion_service, self.controller, self.local_port)
135
+ self.storage.save_identity(h, t, k)
136
+ self.local_onion_hash = clean_hash(h)
137
+ if not self.display_name: self.display_name = f"peer-{self.local_onion_hash[:6]}"
138
+
139
+ self.server = PeerServer("127.0.0.1", self.local_port, self.local_onion_hash, self.display_name, self.register_session)
140
+ await self.server.start()
141
+ except Exception as e:
142
+ self.notify(f"Init error: {e}", severity="error")
143
+
144
+ async def register_session(self, r, w, key, h, n, init):
145
+ h = clean_hash(h)
146
+ if h == self.local_onion_hash: return
147
+
148
+ # Mutual Discovery
149
+ if not self.storage.find_alias_by_onion(h):
150
+ self.storage.add_contact(n or f"peer-{h[:6]}", h)
151
+ elif n:
152
+ alias = self.storage.find_alias_by_onion(h)
153
+ if alias != n: self.storage.update_alias(alias, n)
154
+
155
+ session = PeerSession(r, w, key, h, n, init, self.protocol_callback)
156
+ self.sessions[h] = session
157
+ session.start()
158
+ self.post_message(PeerEvent("status", h))
159
+
160
+ async def protocol_callback(self, type, h, data):
161
+ self.post_message(PeerEvent(type, h, data))
162
+
163
+ def write_message_to_log(self, sender: str, text: str, ts: str, h: str):
164
+ log = self.query_one("#chat-log", RichLog)
165
+ is_me = (sender == "me")
166
+
167
+ if is_me:
168
+ who = self.display_name or "You"
169
+ bg_color = "on #2b5278"
170
+ align_tag = "right"
171
+ else:
172
+ who = self.storage.find_alias_by_onion(h) or f"peer-{h[:6]}"
173
+ bg_color = "on #182533"
174
+ align_tag = "left"
175
+
176
+ max_w = max(20, log.size.width - 10) if log.size.width > 15 else 50
177
+ wrapped_lines = []
178
+ for paragraph in text.splitlines():
179
+ if not paragraph:
180
+ wrapped_lines.append("")
181
+ else:
182
+ while len(paragraph) > max_w:
183
+ wrapped_lines.append(paragraph[:max_w])
184
+ paragraph = paragraph[max_w:]
185
+ wrapped_lines.append(paragraph)
186
+
187
+ lines = wrapped_lines if wrapped_lines else [""]
188
+ max_content_len = max(len(l) for l in lines) if lines else 0
189
+ width = max(len(who) + 2, max_content_len + 2)
190
+
191
+ content_lines = []
192
+ for l in lines:
193
+ content_lines.append(f" {l.ljust(width - 1)}")
194
+ box_str = "\n".join(content_lines)
195
+ rendered = f"[{bg_color}]{box_str}[/]"
196
+
197
+ log.write("")
198
+ log.write(f"[{align_tag}][dim] {who} [/][/{align_tag}]")
199
+ log.write(f"[{align_tag}]{rendered}[/{align_tag}]")
200
+ log.write(f"[{align_tag}][dim] {ts} [/][/{align_tag}]")
201
+ log.write("")
202
+
203
+ def on_peer_event(self, event: PeerEvent):
204
+ if self._closing or not self.is_mounted: return
205
+ h = event.peer_hash
206
+ if event.type == "chat":
207
+ msg = {"sender": "peer", "text": event.data["m"], "ts": time.strftime("%H:%M")}
208
+ if h not in self.chat_buffers: self.chat_buffers[h] = []
209
+ self.chat_buffers[h].append(msg)
210
+
211
+ if h == self.active_peer_hash:
212
+ self.write_message_to_log("peer", msg["text"], msg["ts"], h)
213
+ else:
214
+ self.unread_counts[h] = self.unread_counts.get(h, 0) + 1
215
+ self.notify(f"New message from {h[:6]}")
216
+ self.refresh_contacts()
217
+ elif event.type in ("status", "closed"):
218
+ if event.type == "closed": self.sessions.pop(h, None)
219
+ self.refresh_contacts()
220
+
221
+ def refresh_contacts(self) -> None:
222
+ if not self.is_mounted or self._closing or getattr(self, "_pruning", False):
223
+ return
224
+
225
+ try:
226
+ lv = self.query_one("#contacts-list", ListView)
227
+ except Exception:
228
+ return
229
+
230
+ if not lv.is_mounted or not lv.is_attached:
231
+ return
232
+
233
+ lv.clear()
234
+ items = []
235
+ seen_hashes = set()
236
+ for alias, data in self.storage.list_contacts().items():
237
+ if isinstance(data, dict):
238
+ h = clean_hash(data.get("onion", ""))
239
+ name = data.get("nickname") or alias
240
+ else:
241
+ h = clean_hash(data)
242
+ name = alias
243
+
244
+ if not h or h == self.local_onion_hash or h in seen_hashes:
245
+ continue
246
+ seen_hashes.add(h)
247
+
248
+ cls = "online" if h in self.sessions else "offline"
249
+ status = "●" if cls == "online" else "○"
250
+ unread_cnt = self.unread_counts.get(h, 0)
251
+ unread = f" [{unread_cnt}]" if unread_cnt > 0 else ""
252
+ label = f"[{cls}]{status}[/] {name}{unread}"
253
+ items.append(ListItem(Label(label), name=h))
254
+
255
+ if items:
256
+ lv.extend(items)
257
+
258
+ def on_list_view_selected(self, event: ListView.Selected):
259
+ h = event.item.name
260
+ alias = self.storage.find_alias_by_onion(h) or f"peer-{h[:6]}"
261
+ self.active_peer_hash = h
262
+ self.unread_counts[h] = 0
263
+
264
+ status_text = "[E2EE Secured]" if h in self.sessions else "(Offline)"
265
+ self.query_one("#chat-header", Static).update(f"Chat: {alias} {status_text}")
266
+
267
+ log = self.query_one("#chat-log", RichLog)
268
+ log.clear()
269
+ for m in self.chat_buffers.get(h, []):
270
+ self.write_message_to_log(m["sender"], m["text"], m["ts"], h)
271
+
272
+ if h not in self.sessions:
273
+ self.run_worker(self._connect_to_peer(h, alias))
274
+
275
+ self.refresh_contacts()
276
+
277
+ async def on_input_submitted(self, event: Input.Submitted):
278
+ text = event.value.strip()
279
+ h = self.active_peer_hash
280
+ if not text or not h or h not in self.sessions: return
281
+ event.input.value = ""
282
+ ts = time.strftime("%H:%M")
283
+ await self.sessions[h].send_op(OP_CHAT, {"m": text, "ts": ts})
284
+ msg = {"sender": "me", "text": text, "ts": ts}
285
+ if h not in self.chat_buffers: self.chat_buffers[h] = []
286
+ self.chat_buffers[h].append(msg)
287
+ self.write_message_to_log("me", text, ts, h)
288
+
289
+ async def on_unmount(self):
290
+ self._closing = True
291
+ for s in list(self.sessions.values()):
292
+ try: await s.close()
293
+ except Exception: pass
294
+ self.sessions.clear()
295
+
296
+ if hasattr(self, 'server') and self.server and self.server.server:
297
+ try:
298
+ self.server.server.close()
299
+ await self.server.server.wait_closed()
300
+ except Exception: pass
301
+
302
+ if hasattr(self, 'controller') and self.controller:
303
+ try:
304
+ await asyncio.to_thread(self.controller.close)
305
+ except Exception: pass
306
+
307
+ async def action_quit(self):
308
+ import os, sys
309
+ os._exit(0)
310
+
311
+
@@ -0,0 +1,25 @@
1
+ from textual.command import Provider, Hit, Hits
2
+
3
+ class TorkCommandProvider(Provider):
4
+ async def search(self, query: str) -> Hits:
5
+ matcher = self.matcher(query)
6
+
7
+ title_copy = "Copy My Onion ID"
8
+ score_copy = matcher.match(title_copy) if query else 0
9
+ if not query or score_copy > 0:
10
+ yield Hit(
11
+ score_copy if query else 1.0,
12
+ matcher.highlight(title_copy) if query else title_copy,
13
+ self.app.action_copy_onion_id,
14
+ help="Copy your 56-character Onion ID (without .onion suffix) to system clipboard",
15
+ )
16
+
17
+ title_add = "Add Contact"
18
+ score_add = matcher.match(title_add) if query else 0
19
+ if not query or score_add > 0:
20
+ yield Hit(
21
+ score_add if query else 1.0,
22
+ matcher.highlight(title_add) if query else title_add,
23
+ self.app.action_prompt_add_contact,
24
+ help="Add a contact by pasting their 56-character onion hash (nickname auto-syncs)",
25
+ )
@@ -0,0 +1,75 @@
1
+ from textual.app import ComposeResult
2
+ from textual.screen import ModalScreen
3
+ from textual.widgets import Input, Static
4
+ from textual.containers import Container
5
+ from textual import events
6
+ from tork.config import normalize_and_validate_onion
7
+
8
+ class AddContactModal(ModalScreen[str | None]):
9
+ """Command-palette-styled input for pasting a 56-char onion hash."""
10
+
11
+ CSS = """
12
+ AddContactModal {
13
+ align: center top;
14
+ }
15
+ AddContactModal > #palette-box {
16
+ width: 70%;
17
+ max-width: 90;
18
+ height: auto;
19
+ margin-top: 3;
20
+ border: thick $accent;
21
+ background: $surface;
22
+ padding: 0 0;
23
+ }
24
+ AddContactModal #onion-input {
25
+ border: none;
26
+ background: $surface;
27
+ width: 100%;
28
+ height: 3;
29
+ }
30
+ AddContactModal #hint-error {
31
+ color: $error;
32
+ background: $surface;
33
+ width: 100%;
34
+ height: auto;
35
+ padding: 0 1;
36
+ display: none;
37
+ }
38
+ AddContactModal #hint-error.visible {
39
+ display: block;
40
+ }
41
+ """
42
+
43
+ BINDINGS = [("escape", "cancel", "Cancel")]
44
+
45
+ def compose(self) -> ComposeResult:
46
+ with Container(id="palette-box"):
47
+ yield Input(
48
+ placeholder="Paste 56-character Onion ID and press Enter... (Esc to cancel)",
49
+ id="onion-input",
50
+ )
51
+ yield Static("Invalid Onion ID: expected 56-character base32 hash.", id="hint-error")
52
+
53
+ def on_mount(self) -> None:
54
+ self.query_one("#onion-input", Input).focus()
55
+
56
+ def action_cancel(self) -> None:
57
+ self.dismiss(None)
58
+
59
+ def _show_error(self) -> None:
60
+ err = self.query_one("#hint-error", Static)
61
+ err.add_class("visible")
62
+ inp = self.query_one("#onion-input", Input)
63
+ inp.value = ""
64
+ self.set_timer(0.4, lambda: err.remove_class("visible"))
65
+
66
+ def on_input_submitted(self, event: Input.Submitted) -> None:
67
+ raw = event.value.strip()
68
+ if not raw:
69
+ self.dismiss(None)
70
+ return
71
+ is_valid, normalized, err = normalize_and_validate_onion(raw)
72
+ if not is_valid or len(normalized.removesuffix(".onion")) != 56:
73
+ self._show_error()
74
+ return
75
+ self.dismiss(normalized.removesuffix(".onion"))