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.
- tortalk-1.0.2/.github/workflows/publish.yml +31 -0
- tortalk-1.0.2/.gitignore +10 -0
- tortalk-1.0.2/PKG-INFO +36 -0
- tortalk-1.0.2/README.md +15 -0
- tortalk-1.0.2/pyproject.toml +33 -0
- tortalk-1.0.2/requirements.txt +16 -0
- tortalk-1.0.2/tortalk/__init__.py +1 -0
- tortalk-1.0.2/tortalk/__main__.py +4 -0
- tortalk-1.0.2/tortalk/cli.py +283 -0
- tortalk-1.0.2/tortalk/config.py +60 -0
- tortalk-1.0.2/tortalk/crypto.py +36 -0
- tortalk-1.0.2/tortalk/network/__init__.py +0 -0
- tortalk-1.0.2/tortalk/network/client.py +18 -0
- tortalk-1.0.2/tortalk/network/protocol.py +149 -0
- tortalk-1.0.2/tortalk/network/server.py +31 -0
- tortalk-1.0.2/tortalk/network/tor.py +35 -0
- tortalk-1.0.2/tortalk/storage.py +131 -0
- tortalk-1.0.2/tortalk/ui/app.py +311 -0
- tortalk-1.0.2/tortalk/ui/commands.py +25 -0
- tortalk-1.0.2/tortalk/ui/modals.py +75 -0
|
@@ -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
|
tortalk-1.0.2/.gitignore
ADDED
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
|
+
```
|
tortalk-1.0.2/README.md
ADDED
|
@@ -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,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"))
|