stealth-message-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stealth_cli/__init__.py +0 -0
- stealth_cli/__main__.py +711 -0
- stealth_cli/config.py +132 -0
- stealth_cli/crypto/__init__.py +0 -0
- stealth_cli/crypto/keys.py +120 -0
- stealth_cli/crypto/messages.py +130 -0
- stealth_cli/exceptions.py +29 -0
- stealth_cli/network/__init__.py +0 -0
- stealth_cli/network/client.py +519 -0
- stealth_cli/network/server.py +690 -0
- stealth_cli/ui/__init__.py +0 -0
- stealth_cli/ui/chat.py +1048 -0
- stealth_cli/ui/setup.py +212 -0
- stealth_message_cli-0.1.0.dist-info/METADATA +72 -0
- stealth_message_cli-0.1.0.dist-info/RECORD +18 -0
- stealth_message_cli-0.1.0.dist-info/WHEEL +5 -0
- stealth_message_cli-0.1.0.dist-info/entry_points.txt +2 -0
- stealth_message_cli-0.1.0.dist-info/top_level.txt +1 -0
stealth_cli/__init__.py
ADDED
|
File without changes
|
stealth_cli/__main__.py
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
"""Entry point for stealth-message CLI.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python -m stealth_cli # auto-detect mode (prompts if needed)
|
|
5
|
+
python -m stealth_cli --host # host mode on default port 8765
|
|
6
|
+
python -m stealth_cli --host 9000 # host mode on custom port
|
|
7
|
+
python -m stealth_cli --join ws://192.168.1.10:8765
|
|
8
|
+
|
|
9
|
+
Flow:
|
|
10
|
+
1. First use → run setup wizard → save keypair → open chat
|
|
11
|
+
2. Known user → ask passphrase → open chat
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import asyncio
|
|
18
|
+
import logging
|
|
19
|
+
import sys
|
|
20
|
+
import warnings
|
|
21
|
+
|
|
22
|
+
from prompt_toolkit import PromptSession
|
|
23
|
+
from prompt_toolkit.formatted_text import HTML
|
|
24
|
+
from prompt_toolkit.styles import Style
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
from rich.markdown import Markdown
|
|
27
|
+
from rich.padding import Padding
|
|
28
|
+
from rich.panel import Panel
|
|
29
|
+
from rich.rule import Rule
|
|
30
|
+
from rich.text import Text
|
|
31
|
+
|
|
32
|
+
from stealth_cli import config
|
|
33
|
+
from stealth_cli.crypto.keys import load_private_key
|
|
34
|
+
from stealth_cli.network.client import query_rooms
|
|
35
|
+
from stealth_cli.ui.chat import run_chat
|
|
36
|
+
from stealth_cli.ui.setup import run_setup
|
|
37
|
+
|
|
38
|
+
console = Console()
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
# Suppress known pgpy warnings that do not affect crypto correctness.
|
|
42
|
+
# These are unimplemented features inside pgpy itself (self-sig parsing,
|
|
43
|
+
# revocation checks, flags) and a benign compression preference mismatch.
|
|
44
|
+
warnings.filterwarnings("ignore", message=".*compression algorithm.*", category=UserWarning)
|
|
45
|
+
warnings.filterwarnings("ignore", message=".*Self-sigs verification.*", category=UserWarning)
|
|
46
|
+
warnings.filterwarnings("ignore", message=".*Revocation checks.*", category=UserWarning)
|
|
47
|
+
warnings.filterwarnings("ignore", message=".*Flags.*checks are not yet implemented.*", category=UserWarning)
|
|
48
|
+
warnings.filterwarnings("ignore", message=".*TripleDES.*", category=UserWarning)
|
|
49
|
+
|
|
50
|
+
_STYLE = Style.from_dict({"prompt": "bold cyan"})
|
|
51
|
+
|
|
52
|
+
DEFAULT_PORT = 8765
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------------------------------- #
|
|
56
|
+
# CLI argument parsing #
|
|
57
|
+
# --------------------------------------------------------------------------- #
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_args() -> argparse.Namespace:
|
|
61
|
+
parser = argparse.ArgumentParser(
|
|
62
|
+
prog="stealth-cli",
|
|
63
|
+
description="End-to-end encrypted PGP chat — no server, no accounts.",
|
|
64
|
+
epilog="Run with --manual for the full user guide.",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
mode = parser.add_mutually_exclusive_group()
|
|
68
|
+
mode.add_argument(
|
|
69
|
+
"--host",
|
|
70
|
+
nargs="?",
|
|
71
|
+
const=DEFAULT_PORT,
|
|
72
|
+
type=int,
|
|
73
|
+
metavar="PORT",
|
|
74
|
+
help=f"Host a chat session on PORT (default: {DEFAULT_PORT})",
|
|
75
|
+
)
|
|
76
|
+
mode.add_argument(
|
|
77
|
+
"--join",
|
|
78
|
+
metavar="URI",
|
|
79
|
+
help="Join a session at ws://HOST:PORT",
|
|
80
|
+
)
|
|
81
|
+
mode.add_argument(
|
|
82
|
+
"--manual",
|
|
83
|
+
action="store_true",
|
|
84
|
+
help="Show the full user manual and exit",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--rooms",
|
|
89
|
+
metavar="ROOMS",
|
|
90
|
+
help=(
|
|
91
|
+
"Comma-separated list of room names to create (host mode only). "
|
|
92
|
+
"Example: --rooms pepe,juan Each room admits exactly one peer."
|
|
93
|
+
),
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--room",
|
|
97
|
+
metavar="ROOM",
|
|
98
|
+
default="default",
|
|
99
|
+
help="Room to join (join mode only, default: 'default')",
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--reset",
|
|
103
|
+
action="store_true",
|
|
104
|
+
help="Delete the saved keypair and start the setup wizard to create a new identity",
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--debug",
|
|
108
|
+
action="store_true",
|
|
109
|
+
help="Enable debug logging",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return parser.parse_args()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# --------------------------------------------------------------------------- #
|
|
116
|
+
# Async main #
|
|
117
|
+
# --------------------------------------------------------------------------- #
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
async def _async_main() -> int:
|
|
121
|
+
args = _parse_args()
|
|
122
|
+
|
|
123
|
+
if args.debug:
|
|
124
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
125
|
+
|
|
126
|
+
if args.manual:
|
|
127
|
+
_print_manual()
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------------ #
|
|
131
|
+
# Step 0 — --reset: wipe keypair and force the setup wizard. #
|
|
132
|
+
# ------------------------------------------------------------------ #
|
|
133
|
+
if args.reset:
|
|
134
|
+
if config.is_first_use():
|
|
135
|
+
console.print("[dim]No saved identity found — nothing to reset.[/dim]")
|
|
136
|
+
else:
|
|
137
|
+
config.delete_keypair()
|
|
138
|
+
console.print("[yellow]Identity deleted.[/yellow] Starting setup wizard…\n")
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------ #
|
|
141
|
+
# Step 1 — First use: run the setup wizard. #
|
|
142
|
+
# ------------------------------------------------------------------ #
|
|
143
|
+
if config.is_first_use():
|
|
144
|
+
alias, armored_private, passphrase = await run_setup()
|
|
145
|
+
else:
|
|
146
|
+
# ------------------------------------------------------------------ #
|
|
147
|
+
# Step 2 — Known user: load keys and ask for passphrase. #
|
|
148
|
+
# ------------------------------------------------------------------ #
|
|
149
|
+
alias = config.load_alias() or "unknown"
|
|
150
|
+
armored_private = config.load_armored_private()
|
|
151
|
+
passphrase = await _prompt_passphrase(alias)
|
|
152
|
+
|
|
153
|
+
# Validate passphrase eagerly so the user gets immediate feedback.
|
|
154
|
+
if not _validate_passphrase(armored_private, passphrase):
|
|
155
|
+
console.print("[red]Wrong passphrase. Exiting.[/red]")
|
|
156
|
+
return 1
|
|
157
|
+
|
|
158
|
+
# ------------------------------------------------------------------ #
|
|
159
|
+
# Step 3 — Determine chat mode from CLI flags or interactive prompt. #
|
|
160
|
+
# ------------------------------------------------------------------ #
|
|
161
|
+
rooms: list[str] | None = None
|
|
162
|
+
room: str = "default"
|
|
163
|
+
|
|
164
|
+
if args.host is not None:
|
|
165
|
+
mode = "host"
|
|
166
|
+
port = args.host
|
|
167
|
+
uri = None
|
|
168
|
+
if args.rooms:
|
|
169
|
+
rooms = [r.strip() for r in args.rooms.split(",") if r.strip()]
|
|
170
|
+
elif args.join is not None:
|
|
171
|
+
mode = "join"
|
|
172
|
+
port = DEFAULT_PORT
|
|
173
|
+
uri = args.join
|
|
174
|
+
if not uri.startswith("ws://") and not uri.startswith("wss://"):
|
|
175
|
+
uri = "ws://" + uri
|
|
176
|
+
room = args.room or "default"
|
|
177
|
+
else:
|
|
178
|
+
mode, port, uri, rooms, room = await _prompt_mode()
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------------ #
|
|
181
|
+
# Step 4 — Launch the chat screen. #
|
|
182
|
+
# ------------------------------------------------------------------ #
|
|
183
|
+
try:
|
|
184
|
+
await run_chat(
|
|
185
|
+
mode=mode, # type: ignore[arg-type]
|
|
186
|
+
alias=alias,
|
|
187
|
+
armored_private=armored_private,
|
|
188
|
+
passphrase=passphrase,
|
|
189
|
+
port=port,
|
|
190
|
+
uri=uri,
|
|
191
|
+
rooms=rooms,
|
|
192
|
+
room=room,
|
|
193
|
+
)
|
|
194
|
+
except KeyboardInterrupt:
|
|
195
|
+
pass
|
|
196
|
+
except Exception as exc:
|
|
197
|
+
if args.debug:
|
|
198
|
+
raise
|
|
199
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
200
|
+
return 1
|
|
201
|
+
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# --------------------------------------------------------------------------- #
|
|
206
|
+
# Interactive helpers #
|
|
207
|
+
# --------------------------------------------------------------------------- #
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
async def _print_room_list(uri: str) -> None:
|
|
211
|
+
"""Query the server for room info and print a formatted list."""
|
|
212
|
+
from rich.table import Table
|
|
213
|
+
|
|
214
|
+
console.print(f"[dim]Fetching rooms from[/dim] [bold]{uri}[/bold][dim]…[/dim]")
|
|
215
|
+
rooms = await query_rooms(uri)
|
|
216
|
+
if not rooms:
|
|
217
|
+
console.print("[yellow] Could not retrieve room list (server may not support it).[/yellow]")
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
t = Table.grid(padding=(0, 2))
|
|
221
|
+
t.add_column(no_wrap=True) # room name
|
|
222
|
+
t.add_column(no_wrap=True) # kind badge
|
|
223
|
+
t.add_column() # status
|
|
224
|
+
|
|
225
|
+
for room in rooms:
|
|
226
|
+
room_id = str(room.get("id", ""))
|
|
227
|
+
kind = str(room.get("kind", "1:1"))
|
|
228
|
+
peers = int(room.get("peers", 0))
|
|
229
|
+
|
|
230
|
+
if kind == "group":
|
|
231
|
+
badge = "[yellow]group[/yellow]"
|
|
232
|
+
if peers == 0:
|
|
233
|
+
status = "[dim]empty — host only[/dim]"
|
|
234
|
+
elif peers == 1:
|
|
235
|
+
status = f"[dim]host + {peers} user[/dim]"
|
|
236
|
+
else:
|
|
237
|
+
status = f"[dim]host + {peers} users[/dim]"
|
|
238
|
+
else:
|
|
239
|
+
badge = "[cyan]1:1[/cyan]"
|
|
240
|
+
available = bool(room.get("available", True))
|
|
241
|
+
if available:
|
|
242
|
+
status = "[green]available[/green]"
|
|
243
|
+
else:
|
|
244
|
+
status = "[red]occupied[/red]"
|
|
245
|
+
|
|
246
|
+
t.add_row(f" [bold]{room_id}[/bold]", badge, status)
|
|
247
|
+
|
|
248
|
+
console.print()
|
|
249
|
+
console.print("[bold]Available rooms:[/bold]")
|
|
250
|
+
console.print(t)
|
|
251
|
+
console.print()
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
async def _prompt_passphrase(alias: str) -> str:
|
|
255
|
+
"""Ask the user for their passphrase at startup."""
|
|
256
|
+
session: PromptSession[str] = PromptSession(style=_STYLE)
|
|
257
|
+
console.print(f"[cyan]Welcome back,[/cyan] [bold]{alias}[/bold]")
|
|
258
|
+
passphrase: str = await session.prompt_async(
|
|
259
|
+
HTML("<prompt>Passphrase: </prompt>"),
|
|
260
|
+
is_password=True,
|
|
261
|
+
)
|
|
262
|
+
return passphrase
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _validate_passphrase(armored_private: str, passphrase: str) -> bool:
|
|
266
|
+
"""Return True if the passphrase successfully unlocks the private key."""
|
|
267
|
+
try:
|
|
268
|
+
load_private_key(armored_private, passphrase)
|
|
269
|
+
return True
|
|
270
|
+
except Exception:
|
|
271
|
+
return False
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def _prompt_mode() -> tuple[str, int, str | None, list[str] | None, str]:
|
|
275
|
+
"""Interactively ask whether to host or join."""
|
|
276
|
+
session: PromptSession[str] = PromptSession(style=_STYLE)
|
|
277
|
+
|
|
278
|
+
console.print()
|
|
279
|
+
console.print("[bold]What do you want to do?[/bold]")
|
|
280
|
+
console.print(" [cyan]h[/cyan] Host a new session")
|
|
281
|
+
console.print(" [cyan]j[/cyan] Join an existing session")
|
|
282
|
+
console.print()
|
|
283
|
+
|
|
284
|
+
while True:
|
|
285
|
+
choice: str = await session.prompt_async(
|
|
286
|
+
HTML("<prompt>Choice [h/j]: </prompt>"),
|
|
287
|
+
)
|
|
288
|
+
choice = choice.strip().lower()
|
|
289
|
+
if choice in ("h", "host"):
|
|
290
|
+
port_str: str = await session.prompt_async(
|
|
291
|
+
HTML(f"<prompt>Port [{DEFAULT_PORT}]: </prompt>"),
|
|
292
|
+
)
|
|
293
|
+
port = int(port_str.strip()) if port_str.strip().isdigit() else DEFAULT_PORT
|
|
294
|
+
|
|
295
|
+
rooms_str: str = await session.prompt_async(
|
|
296
|
+
HTML("<prompt>Rooms (comma-separated, blank for single): </prompt>"),
|
|
297
|
+
)
|
|
298
|
+
rooms_str = rooms_str.strip()
|
|
299
|
+
rooms: list[str] | None = (
|
|
300
|
+
[r.strip() for r in rooms_str.split(",") if r.strip()]
|
|
301
|
+
if rooms_str
|
|
302
|
+
else None
|
|
303
|
+
)
|
|
304
|
+
return "host", port, None, rooms, "default"
|
|
305
|
+
|
|
306
|
+
if choice in ("j", "join"):
|
|
307
|
+
uri: str = await session.prompt_async(
|
|
308
|
+
HTML("<prompt>Server URI (ws://host:port): </prompt>"),
|
|
309
|
+
)
|
|
310
|
+
uri = uri.strip()
|
|
311
|
+
if uri and not uri.startswith("ws://") and not uri.startswith("wss://"):
|
|
312
|
+
uri = "ws://" + uri
|
|
313
|
+
await _print_room_list(uri)
|
|
314
|
+
room_str: str = await session.prompt_async(
|
|
315
|
+
HTML("<prompt>Room [default]: </prompt>"),
|
|
316
|
+
)
|
|
317
|
+
room = room_str.strip() or "default"
|
|
318
|
+
return "join", DEFAULT_PORT, uri, None, room
|
|
319
|
+
|
|
320
|
+
console.print("[red]Please enter 'h' or 'j'.[/red]")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# --------------------------------------------------------------------------- #
|
|
324
|
+
# Manual #
|
|
325
|
+
# --------------------------------------------------------------------------- #
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _print_manual() -> None:
|
|
329
|
+
"""Print the full user manual with Rich formatting."""
|
|
330
|
+
c = Console()
|
|
331
|
+
|
|
332
|
+
# Header
|
|
333
|
+
c.print()
|
|
334
|
+
c.print(
|
|
335
|
+
Panel(
|
|
336
|
+
Text.assemble(
|
|
337
|
+
("stealth-message", "bold white"),
|
|
338
|
+
" — ",
|
|
339
|
+
("User Manual", "bold cyan"),
|
|
340
|
+
"\n",
|
|
341
|
+
("End-to-end encrypted PGP chat. No server. No accounts. No metadata.", "dim"),
|
|
342
|
+
),
|
|
343
|
+
border_style="cyan",
|
|
344
|
+
padding=(1, 4),
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
manual = """\
|
|
349
|
+
## How it works
|
|
350
|
+
|
|
351
|
+
Participants communicate directly, machine to machine, over a WebSocket
|
|
352
|
+
connection. Every message is encrypted with the recipient's PGP public key
|
|
353
|
+
and signed with the sender's private key before leaving the machine.
|
|
354
|
+
No server ever sees the content.
|
|
355
|
+
|
|
356
|
+
One participant acts as **host** (starts the server) and the others **join**.
|
|
357
|
+
All roles send and receive messages equally once connected.
|
|
358
|
+
|
|
359
|
+
---
|
|
360
|
+
|
|
361
|
+
## First use
|
|
362
|
+
|
|
363
|
+
The first time you run the program, a setup wizard starts automatically:
|
|
364
|
+
|
|
365
|
+
```
|
|
366
|
+
python -m stealth_cli
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
The wizard asks for:
|
|
370
|
+
|
|
371
|
+
- **Alias** — your display name, visible to peers (max 64 chars).
|
|
372
|
+
- **Passphrase** — protects your private key on disk (min 8 chars, asked twice).
|
|
373
|
+
|
|
374
|
+
An RSA-4096 key pair is then generated and saved to disk:
|
|
375
|
+
|
|
376
|
+
- **macOS:** `~/Library/Application Support/stealth-message/`
|
|
377
|
+
- **Linux / WSL:** `~/.config/stealth-message/`
|
|
378
|
+
|
|
379
|
+
Your **fingerprint** is shown at the end. Share it with your peers over an
|
|
380
|
+
independent channel (in person, by phone) so they can verify your identity.
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
384
|
+
## Starting a session
|
|
385
|
+
|
|
386
|
+
### Alice — host mode (single 1-on-1 room)
|
|
387
|
+
|
|
388
|
+
```
|
|
389
|
+
python -m stealth_cli --host # default port 8765
|
|
390
|
+
python -m stealth_cli --host 9000 # custom port
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
### Alice — host mode (multiple rooms)
|
|
394
|
+
|
|
395
|
+
```
|
|
396
|
+
python -m stealth_cli --host --rooms bob,carol,team
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
This creates three independent rooms. Peers connect to a specific room by name.
|
|
400
|
+
|
|
401
|
+
### Bob — join mode
|
|
402
|
+
|
|
403
|
+
```
|
|
404
|
+
python -m stealth_cli --join ALICE_IP:8765 # ws:// added automatically
|
|
405
|
+
python -m stealth_cli --join ALICE_IP:8765 --room bob
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
### Interactive mode (no flags)
|
|
409
|
+
|
|
410
|
+
```
|
|
411
|
+
python -m stealth_cli
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
When joining interactively, after entering the server address the program
|
|
415
|
+
fetches and displays the available rooms before asking which one to join:
|
|
416
|
+
|
|
417
|
+
```
|
|
418
|
+
Available rooms:
|
|
419
|
+
lobby 1:1 available
|
|
420
|
+
work 1:1 occupied
|
|
421
|
+
team group host + 2 users
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
Room names are shown but connected user names are never disclosed.
|
|
425
|
+
|
|
426
|
+
---
|
|
427
|
+
|
|
428
|
+
## Room types
|
|
429
|
+
|
|
430
|
+
### 1-on-1 rooms (default)
|
|
431
|
+
|
|
432
|
+
Each room admits exactly **one peer**. A second peer trying to connect gets
|
|
433
|
+
error 4006 (room occupied). The host can hold multiple 1-on-1 rooms in parallel
|
|
434
|
+
and switch between them with `/switch`.
|
|
435
|
+
|
|
436
|
+
### Group rooms
|
|
437
|
+
|
|
438
|
+
Group rooms admit **multiple peers** with host approval:
|
|
439
|
+
|
|
440
|
+
```
|
|
441
|
+
[Alice@room1] /group team # convert a room to group mode
|
|
442
|
+
[Alice@room1] /move Bob team # invite Bob — pre-approved, no prompt
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
When a new peer tries to join a group room that already has someone:
|
|
446
|
+
|
|
447
|
+
```
|
|
448
|
+
⚠ Join request: Carol wants to enter room team
|
|
449
|
+
FP: XXXX XXXX XXXX ...
|
|
450
|
+
/allow Carol or /deny Carol
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
- `/allow Carol` → Carol enters the room
|
|
454
|
+
- `/deny Carol` → Carol is rejected
|
|
455
|
+
|
|
456
|
+
In group rooms, messages are forwarded to **all** other peers in the room.
|
|
457
|
+
The host re-encrypts each message for each recipient individually.
|
|
458
|
+
|
|
459
|
+
### Room discovery
|
|
460
|
+
|
|
461
|
+
After connecting, every peer receives the list of group rooms on the server.
|
|
462
|
+
Running `/rooms` shows all known rooms including ones not yet visited:
|
|
463
|
+
|
|
464
|
+
```
|
|
465
|
+
▶ team ✓ Alice, Bob
|
|
466
|
+
lobby waiting for peer…
|
|
467
|
+
open-chat group /switch to join
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
---
|
|
471
|
+
|
|
472
|
+
## Connecting over the internet
|
|
473
|
+
|
|
474
|
+
The host needs a publicly reachable IP. Two options:
|
|
475
|
+
|
|
476
|
+
**Option A — Port forwarding (no third-party software)**
|
|
477
|
+
|
|
478
|
+
1. Find your public IP: `curl ifconfig.me`
|
|
479
|
+
2. In your router: NAT → Port Forwarding → TCP port 8765 → your local IP.
|
|
480
|
+
3. Give peers: `ALICE_PUBLIC_IP:8765`
|
|
481
|
+
4. Close the port forwarding rule when you finish.
|
|
482
|
+
|
|
483
|
+
> If port forwarding does not work, your ISP may use CG-NAT.
|
|
484
|
+
> Check: compare the WAN IP shown in your router with `curl ifconfig.me`.
|
|
485
|
+
> If they differ, contact your ISP and request a dedicated public IP.
|
|
486
|
+
|
|
487
|
+
**Option B — Tailscale (no port forwarding)**
|
|
488
|
+
|
|
489
|
+
Tailscale creates a private WireGuard tunnel directly between machines.
|
|
490
|
+
No router configuration needed.
|
|
491
|
+
|
|
492
|
+
1. All participants install Tailscale (free for personal use).
|
|
493
|
+
2. Alice shares her device with peers from the Tailscale web console
|
|
494
|
+
("Share node" — they only see Alice's machine, not her whole network).
|
|
495
|
+
3. Run `tailscale status` to see each other's `100.x.x.x` addresses.
|
|
496
|
+
4. Alice: `python -m stealth_cli --host`
|
|
497
|
+
5. Others: `python -m stealth_cli --join ALICE_TAILSCALE_IP:8765 --room <name>`
|
|
498
|
+
6. Revoke the share when done.
|
|
499
|
+
|
|
500
|
+
> With Tailscale, messages travel encrypted by WireGuard AND by PGP —
|
|
501
|
+
> two independent layers.
|
|
502
|
+
|
|
503
|
+
---
|
|
504
|
+
|
|
505
|
+
## Chat commands — all users
|
|
506
|
+
|
|
507
|
+
| Command | Action |
|
|
508
|
+
|---------|--------|
|
|
509
|
+
| `/fp` | Show the current peer's PGP fingerprint |
|
|
510
|
+
| `/rooms` | List all known rooms and their status |
|
|
511
|
+
| `/switch <room>` | Change active room (join mode: reconnects; host mode: changes focus) |
|
|
512
|
+
| `/help` | Show available commands |
|
|
513
|
+
| `/quit` or `/exit` or `/q` | Close the session cleanly |
|
|
514
|
+
| `Ctrl+C` | Also closes the session |
|
|
515
|
+
|
|
516
|
+
## Chat commands — host only
|
|
517
|
+
|
|
518
|
+
| Command | Action |
|
|
519
|
+
|---------|--------|
|
|
520
|
+
| `/new <room>` | Create a new 1-on-1 room at runtime |
|
|
521
|
+
| `/group <room>` | Convert a room to group mode (multiple peers) |
|
|
522
|
+
| `/move <alias> <room>` | Move a peer to a different room (pre-approved, no prompt) |
|
|
523
|
+
| `/allow <alias>` | Approve a pending join request |
|
|
524
|
+
| `/deny <alias>` | Deny a pending join request |
|
|
525
|
+
| `/pending` | List all pending join requests |
|
|
526
|
+
| `/disconnect [alias]` | Force-disconnect a peer (alias optional in 1-on-1 rooms) |
|
|
527
|
+
|
|
528
|
+
---
|
|
529
|
+
|
|
530
|
+
## Example: Alice hosts, Bob and Carol join separate 1-on-1 rooms
|
|
531
|
+
|
|
532
|
+
**Alice (host):**
|
|
533
|
+
```
|
|
534
|
+
python -m stealth_cli --host --rooms bob,carol
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
**Bob:**
|
|
538
|
+
```
|
|
539
|
+
python -m stealth_cli --join ALICE_IP:8765 --room bob
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
**Carol:**
|
|
543
|
+
```
|
|
544
|
+
python -m stealth_cli --join ALICE_IP:8765 --room carol
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
Alice uses `/switch bob` and `/switch carol` to alternate between conversations.
|
|
548
|
+
Neither Bob nor Carol can see each other's messages.
|
|
549
|
+
|
|
550
|
+
---
|
|
551
|
+
|
|
552
|
+
## Example: Alice hosts a group room with Bob and Carol
|
|
553
|
+
|
|
554
|
+
**Alice (host):**
|
|
555
|
+
```
|
|
556
|
+
python -m stealth_cli --host --rooms lobby,team
|
|
557
|
+
[Alice@lobby] /group team # convert team to group mode
|
|
558
|
+
[Alice@lobby] /move Bob team # move Bob — pre-approved
|
|
559
|
+
```
|
|
560
|
+
|
|
561
|
+
**Bob** (already connected to lobby):
|
|
562
|
+
```
|
|
563
|
+
↪ Host is moving you to room team…
|
|
564
|
+
✓ Switched to room team — connected to Alice
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
**Carol** (joins directly):
|
|
568
|
+
```
|
|
569
|
+
python -m stealth_cli --join ALICE_IP:8765 --room team
|
|
570
|
+
⏳ Waiting for host to approve your entry into room team…
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
**Alice approves:**
|
|
574
|
+
```
|
|
575
|
+
⚠ Join request: Carol wants to enter room team
|
|
576
|
+
[Alice@team] /allow Carol
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
Now Alice, Bob and Carol are all in room `team`. Any message sent by
|
|
580
|
+
any of them reaches all the others.
|
|
581
|
+
|
|
582
|
+
---
|
|
583
|
+
|
|
584
|
+
## Identity verification
|
|
585
|
+
|
|
586
|
+
After connecting, both sides see the peer's alias and fingerprint:
|
|
587
|
+
|
|
588
|
+
```
|
|
589
|
+
✓ Connected to Alice [room: bob]
|
|
590
|
+
Fingerprint: F7B3 E55E EA71 1A09 C6C5 0BB7 BA84 DD16 8A77 AA9A
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
**Always verify the fingerprint over an independent channel before trusting
|
|
594
|
+
the conversation.** If the fingerprints match, the connection is authentic.
|
|
595
|
+
If they do not, disconnect immediately.
|
|
596
|
+
|
|
597
|
+
---
|
|
598
|
+
|
|
599
|
+
## Security model
|
|
600
|
+
|
|
601
|
+
| Property | Guarantee |
|
|
602
|
+
|----------|-----------|
|
|
603
|
+
| Message confidentiality | Only the recipient can decrypt (RSA-4096 + AES-256) |
|
|
604
|
+
| Message authenticity | Every message is signed; invalid signatures are rejected |
|
|
605
|
+
| Group room relay | Host re-encrypts per recipient; host sees plaintext during relay |
|
|
606
|
+
| Room isolation | Peers in different rooms cannot read each other's messages |
|
|
607
|
+
| Room discovery | Room list shows counts only — connected user names are never disclosed |
|
|
608
|
+
| Access control | Group rooms require explicit host approval for each new peer |
|
|
609
|
+
| Forward secrecy | Not yet implemented (planned for protocol v2) |
|
|
610
|
+
| Private key storage | Disk-encrypted with your passphrase (AES-256) |
|
|
611
|
+
| Passphrase | Never written to disk, only held in memory during the session |
|
|
612
|
+
| No accounts | Identity is the PGP key — no username, email, or phone number |
|
|
613
|
+
|
|
614
|
+
---
|
|
615
|
+
|
|
616
|
+
## Subsequent uses
|
|
617
|
+
|
|
618
|
+
From the second run onward, the wizard is skipped. The program asks only
|
|
619
|
+
for the passphrase:
|
|
620
|
+
|
|
621
|
+
```
|
|
622
|
+
Welcome back, Alice
|
|
623
|
+
Passphrase: ****
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
A wrong passphrase exits immediately without loading any data.
|
|
627
|
+
|
|
628
|
+
---
|
|
629
|
+
|
|
630
|
+
## Resetting your identity
|
|
631
|
+
|
|
632
|
+
To delete your saved keypair and start over with a new alias:
|
|
633
|
+
|
|
634
|
+
```
|
|
635
|
+
python -m stealth_cli --reset
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
This wipes the stored keys and config and immediately runs the setup wizard
|
|
639
|
+
so you can choose a new alias and generate a fresh RSA-4096 keypair in one step.
|
|
640
|
+
|
|
641
|
+
> **Note:** Your previous fingerprint will be invalidated. Any peer who had it
|
|
642
|
+
> saved will need to verify your new fingerprint before trusting the new identity.
|
|
643
|
+
|
|
644
|
+
---
|
|
645
|
+
|
|
646
|
+
## Room names
|
|
647
|
+
|
|
648
|
+
Room names can contain any characters, including spaces, and can be up to
|
|
649
|
+
64 characters long. When passing a name with spaces on the command line,
|
|
650
|
+
wrap it in quotes:
|
|
651
|
+
|
|
652
|
+
```
|
|
653
|
+
python -m stealth_cli --host --rooms "sala 1","sala 2"
|
|
654
|
+
python -m stealth_cli --join HOST:8765 --room "sala 1"
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
Inside the chat (interactive commands) quotes are not needed — everything
|
|
658
|
+
after the command is taken as the room name:
|
|
659
|
+
|
|
660
|
+
```
|
|
661
|
+
/new sala 1
|
|
662
|
+
/switch sala 1
|
|
663
|
+
/group sala 1
|
|
664
|
+
```
|
|
665
|
+
|
|
666
|
+
---
|
|
667
|
+
|
|
668
|
+
## Flags reference
|
|
669
|
+
|
|
670
|
+
| Flag | Description |
|
|
671
|
+
|------|-------------|
|
|
672
|
+
| `--host [PORT]` | Host a session on PORT (default 8765) |
|
|
673
|
+
| `--rooms ROOMS` | Comma-separated room names (host mode) |
|
|
674
|
+
| `--join URI` | Join a session at host:port (ws:// added automatically) |
|
|
675
|
+
| `--room ROOM` | Room to join (join mode, default: "default") |
|
|
676
|
+
| `--manual` | Show this manual |
|
|
677
|
+
| `--reset` | Delete saved identity and run setup wizard |
|
|
678
|
+
| `--debug` | Enable verbose debug logging |
|
|
679
|
+
| `--help` | Show short usage summary |
|
|
680
|
+
|
|
681
|
+
---
|
|
682
|
+
|
|
683
|
+
## Running the tests
|
|
684
|
+
|
|
685
|
+
```
|
|
686
|
+
cd cli
|
|
687
|
+
source .venv/bin/activate
|
|
688
|
+
pytest tests/ -v
|
|
689
|
+
```
|
|
690
|
+
"""
|
|
691
|
+
|
|
692
|
+
c.print(Padding(Markdown(manual), (0, 2)))
|
|
693
|
+
c.print()
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
# --------------------------------------------------------------------------- #
|
|
697
|
+
# Synchronous entry point #
|
|
698
|
+
# --------------------------------------------------------------------------- #
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def main() -> None:
|
|
702
|
+
"""Synchronous entry point for the stealth-cli console script."""
|
|
703
|
+
try:
|
|
704
|
+
code = asyncio.run(_async_main())
|
|
705
|
+
except KeyboardInterrupt:
|
|
706
|
+
code = 0
|
|
707
|
+
sys.exit(code)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
if __name__ == "__main__":
|
|
711
|
+
main()
|