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/ui/setup.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""First-use setup wizard (alias + passphrase → RSA-4096 keypair).
|
|
2
|
+
|
|
3
|
+
Collects alias and passphrase interactively via prompt_toolkit, generates
|
|
4
|
+
an RSA-4096 keypair, saves it to disk, and returns the credentials needed
|
|
5
|
+
to start the chat session.
|
|
6
|
+
|
|
7
|
+
Usage (called by __main__.py)::
|
|
8
|
+
|
|
9
|
+
alias, armored_private, passphrase = await run_setup()
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from prompt_toolkit import PromptSession
|
|
18
|
+
from prompt_toolkit.formatted_text import HTML
|
|
19
|
+
from prompt_toolkit.styles import Style
|
|
20
|
+
from prompt_toolkit.validation import ValidationError, Validator
|
|
21
|
+
from rich.console import Console
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
24
|
+
from rich.rule import Rule
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
|
|
27
|
+
from stealth_cli import config
|
|
28
|
+
from stealth_cli.crypto.keys import generate_keypair, get_fingerprint
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
_STYLE = Style.from_dict(
|
|
33
|
+
{
|
|
34
|
+
"prompt": "bold cyan",
|
|
35
|
+
"label": "ansicyan",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# --------------------------------------------------------------------------- #
|
|
40
|
+
# Validators #
|
|
41
|
+
# --------------------------------------------------------------------------- #
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _NonEmptyValidator(Validator):
|
|
45
|
+
def __init__(self, field: str, min_len: int = 1) -> None:
|
|
46
|
+
self._field = field
|
|
47
|
+
self._min_len = min_len
|
|
48
|
+
|
|
49
|
+
def validate(self, document): # type: ignore[override]
|
|
50
|
+
if len(document.text.strip()) < self._min_len:
|
|
51
|
+
raise ValidationError(
|
|
52
|
+
message=f"{self._field} must be at least {self._min_len} character(s)",
|
|
53
|
+
cursor_position=len(document.text),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _AliasValidator(_NonEmptyValidator):
|
|
58
|
+
def __init__(self) -> None:
|
|
59
|
+
super().__init__("Alias", min_len=1)
|
|
60
|
+
|
|
61
|
+
def validate(self, document): # type: ignore[override]
|
|
62
|
+
super().validate(document)
|
|
63
|
+
if len(document.text.strip()) > 64:
|
|
64
|
+
raise ValidationError(
|
|
65
|
+
message="Alias must be 64 characters or fewer",
|
|
66
|
+
cursor_position=len(document.text),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class _PassphraseValidator(Validator):
|
|
71
|
+
MIN_LEN = 8
|
|
72
|
+
|
|
73
|
+
def validate(self, document): # type: ignore[override]
|
|
74
|
+
if len(document.text) < self.MIN_LEN:
|
|
75
|
+
raise ValidationError(
|
|
76
|
+
message=f"Passphrase must be at least {self.MIN_LEN} characters",
|
|
77
|
+
cursor_position=len(document.text),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --------------------------------------------------------------------------- #
|
|
82
|
+
# Public entry point #
|
|
83
|
+
# --------------------------------------------------------------------------- #
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def run_setup() -> tuple[str, str, str]:
|
|
87
|
+
"""Interactive first-use wizard.
|
|
88
|
+
|
|
89
|
+
Prompts for alias and passphrase, generates an RSA-4096 keypair,
|
|
90
|
+
persists it to disk, and returns ``(alias, armored_private, passphrase)``.
|
|
91
|
+
"""
|
|
92
|
+
_print_welcome()
|
|
93
|
+
|
|
94
|
+
session: PromptSession[str] = PromptSession(style=_STYLE)
|
|
95
|
+
|
|
96
|
+
alias = await _prompt_alias(session)
|
|
97
|
+
passphrase = await _prompt_passphrase(session)
|
|
98
|
+
|
|
99
|
+
armored_private, armored_public = await _generate_with_spinner(alias, passphrase)
|
|
100
|
+
|
|
101
|
+
config.save_keypair(armored_private, armored_public, alias)
|
|
102
|
+
|
|
103
|
+
_print_success(alias, armored_public)
|
|
104
|
+
|
|
105
|
+
return alias, armored_private, passphrase
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# --------------------------------------------------------------------------- #
|
|
109
|
+
# UI helpers #
|
|
110
|
+
# --------------------------------------------------------------------------- #
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _print_welcome() -> None:
|
|
114
|
+
console.print()
|
|
115
|
+
console.print(
|
|
116
|
+
Panel(
|
|
117
|
+
Text.assemble(
|
|
118
|
+
("stealth-message", "bold white"),
|
|
119
|
+
"\n",
|
|
120
|
+
("End-to-end encrypted PGP chat — no server, no accounts", "dim"),
|
|
121
|
+
),
|
|
122
|
+
border_style="cyan",
|
|
123
|
+
padding=(1, 4),
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
console.print(
|
|
127
|
+
Rule("[cyan]First-time setup[/cyan]", style="dim"),
|
|
128
|
+
)
|
|
129
|
+
console.print()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def _prompt_alias(session: PromptSession[str]) -> str:
|
|
133
|
+
"""Prompt for a display alias (1–64 chars)."""
|
|
134
|
+
console.print("[cyan]Choose a display name[/cyan] (visible to peers, max 64 chars)")
|
|
135
|
+
alias: str = await session.prompt_async(
|
|
136
|
+
HTML("<prompt>Alias: </prompt>"),
|
|
137
|
+
validator=_AliasValidator(),
|
|
138
|
+
validate_while_typing=False,
|
|
139
|
+
)
|
|
140
|
+
return alias.strip()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
async def _prompt_passphrase(session: PromptSession[str]) -> str:
|
|
144
|
+
"""Prompt for passphrase with confirmation."""
|
|
145
|
+
console.print()
|
|
146
|
+
console.print(
|
|
147
|
+
"[cyan]Choose a passphrase[/cyan] — protects your private key on disk"
|
|
148
|
+
)
|
|
149
|
+
console.print("[dim]Minimum 8 characters. You will be asked for it on every start.[/dim]")
|
|
150
|
+
|
|
151
|
+
while True:
|
|
152
|
+
passphrase: str = await session.prompt_async(
|
|
153
|
+
HTML("<prompt>Passphrase: </prompt>"),
|
|
154
|
+
is_password=True,
|
|
155
|
+
validator=_PassphraseValidator(),
|
|
156
|
+
validate_while_typing=False,
|
|
157
|
+
)
|
|
158
|
+
confirm: str = await session.prompt_async(
|
|
159
|
+
HTML("<prompt>Confirm passphrase: </prompt>"),
|
|
160
|
+
is_password=True,
|
|
161
|
+
)
|
|
162
|
+
if passphrase == confirm:
|
|
163
|
+
break
|
|
164
|
+
console.print("[red]Passphrases do not match. Try again.[/red]")
|
|
165
|
+
|
|
166
|
+
return passphrase
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
async def _generate_with_spinner(alias: str, passphrase: str) -> tuple[str, str]:
|
|
170
|
+
"""Generate RSA-4096 keypair in a thread pool with a spinner."""
|
|
171
|
+
console.print()
|
|
172
|
+
|
|
173
|
+
with Progress(
|
|
174
|
+
SpinnerColumn(),
|
|
175
|
+
TextColumn("[cyan]{task.description}"),
|
|
176
|
+
transient=True,
|
|
177
|
+
console=console,
|
|
178
|
+
) as progress:
|
|
179
|
+
task = progress.add_task("Generating RSA-4096 keypair…", total=None)
|
|
180
|
+
|
|
181
|
+
loop = asyncio.get_event_loop()
|
|
182
|
+
armored_private, armored_public = await loop.run_in_executor(
|
|
183
|
+
None, generate_keypair, alias, passphrase
|
|
184
|
+
)
|
|
185
|
+
progress.remove_task(task)
|
|
186
|
+
|
|
187
|
+
return armored_private, armored_public
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _print_success(alias: str, armored_public: str) -> None:
|
|
191
|
+
fp = get_fingerprint(armored_public)
|
|
192
|
+
|
|
193
|
+
console.print("[green]✓[/green] Keypair generated and saved.")
|
|
194
|
+
console.print()
|
|
195
|
+
console.print(
|
|
196
|
+
Panel(
|
|
197
|
+
Text.assemble(
|
|
198
|
+
("Alias: ", "bold"),
|
|
199
|
+
(alias, "cyan"),
|
|
200
|
+
"\n",
|
|
201
|
+
("Fingerprint: ", "bold"),
|
|
202
|
+
(fp, "yellow"),
|
|
203
|
+
),
|
|
204
|
+
title="[bold]Your identity[/bold]",
|
|
205
|
+
border_style="green",
|
|
206
|
+
padding=(1, 3),
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
console.print(
|
|
210
|
+
"[dim]Share your fingerprint out-of-band so peers can verify your identity.[/dim]"
|
|
211
|
+
)
|
|
212
|
+
console.print()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: stealth-message-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Terminal client for stealth-message — end-to-end encrypted PGP chat
|
|
5
|
+
License: GPL-3.0-only
|
|
6
|
+
Project-URL: Homepage, https://syberiancode.com/stealth-message
|
|
7
|
+
Project-URL: Repository, https://github.com/syberiancode/stealth-message
|
|
8
|
+
Project-URL: Issues, https://github.com/syberiancode/stealth-message/issues
|
|
9
|
+
Keywords: chat,encryption,pgp,privacy,end-to-end
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Communications :: Chat
|
|
18
|
+
Classifier: Topic :: Security :: Cryptography
|
|
19
|
+
Requires-Python: <3.13,>=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: pgpy>=0.6.0
|
|
22
|
+
Requires-Dist: websockets>=12.0
|
|
23
|
+
Requires-Dist: rich>=13.0
|
|
24
|
+
Requires-Dist: prompt_toolkit>=3.0
|
|
25
|
+
Requires-Dist: platformdirs>=4.0
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
29
|
+
Requires-Dist: black>=24.0; extra == "dev"
|
|
30
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
31
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
32
|
+
|
|
33
|
+
# stealth-message CLI
|
|
34
|
+
|
|
35
|
+
End-to-end encrypted PGP chat. No central server. No accounts. No content metadata.
|
|
36
|
+
|
|
37
|
+
## Installation
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
curl -fsSL https://syberiancode.com/stealth-message/install.sh | bash
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
**Windows (PowerShell):**
|
|
44
|
+
```powershell
|
|
45
|
+
powershell -c "irm https://syberiancode.com/stealth-message/install.ps1 | iex"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
**Or install directly with pip:**
|
|
49
|
+
```bash
|
|
50
|
+
pip install stealth-message-cli
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Requirements
|
|
54
|
+
|
|
55
|
+
- Python 3.10, 3.11, or 3.12
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
stealth-cli
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Security
|
|
64
|
+
|
|
65
|
+
- RSA-4096 keypair per user
|
|
66
|
+
- Sign-then-encrypt on send, decrypt-then-verify on receive
|
|
67
|
+
- Private key is passphrase-protected on disk
|
|
68
|
+
- Wire encoding: ASCII-armored PGP → Base64 URL-safe
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
GPL-3.0. See [LICENSE](https://github.com/syberiancode/stealth-message/blob/main/LICENSE).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
stealth_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
stealth_cli/__main__.py,sha256=V1-olLs3_5aOYIdLb0q5jbZH1IMyrPlxXQ-BAkgG0qw,22331
|
|
3
|
+
stealth_cli/config.py,sha256=Us73UNRL6qPLlhhIn_M8tbxorCnUUtDssoFUCu6vbwU,4119
|
|
4
|
+
stealth_cli/exceptions.py,sha256=yblXvvVrFzkQIza7kwoM01N2S8BFa-HDFsS1XoJcVoU,870
|
|
5
|
+
stealth_cli/crypto/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
stealth_cli/crypto/keys.py,sha256=M6C1wECQLEKgp96wJ7xnim5Ke9Kej0TohiflW1Ltqnw,3932
|
|
7
|
+
stealth_cli/crypto/messages.py,sha256=HKTL-vYxvfflV4fe88SWlL_jTdULbi9mlgZMimjta7s,4911
|
|
8
|
+
stealth_cli/network/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
stealth_cli/network/client.py,sha256=7zAEn5oc_AqfDNb73vyUVP5bCgA5XgFw4knu0N0CxZc,20738
|
|
10
|
+
stealth_cli/network/server.py,sha256=qtU6jkBR2lWcUpYHGmx7L4uLIPsa96qYw04RPhDar1o,27169
|
|
11
|
+
stealth_cli/ui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
stealth_cli/ui/chat.py,sha256=FpTbB6ejgbimW1SFVxE4pJtdpnTOYfy2qmUPRCAqfmw,40954
|
|
13
|
+
stealth_cli/ui/setup.py,sha256=uUhyXzF_5EGCrJVMSkXdCMi4pahvJXDBJaYRFYYgVBw,6775
|
|
14
|
+
stealth_message_cli-0.1.0.dist-info/METADATA,sha256=TD1i1u0z-tow6Vqnskw91P0SYbZnvf2TN4e81tE2ogQ,2137
|
|
15
|
+
stealth_message_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
16
|
+
stealth_message_cli-0.1.0.dist-info/entry_points.txt,sha256=tdlk9LpnPBPOp_OzWOuAtUprcQxTUspvYmP2YBrWU6o,58
|
|
17
|
+
stealth_message_cli-0.1.0.dist-info/top_level.txt,sha256=AgMse2uy3u79YKPdSSjvZcNtUJCftRHWJGd8b7ZXuvM,12
|
|
18
|
+
stealth_message_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
stealth_cli
|