cyfer 0.2.2__tar.gz → 0.2.3__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.
- {cyfer-0.2.2 → cyfer-0.2.3}/PKG-INFO +1 -1
- cyfer-0.2.3/cyfer/tui/screens/config_screen.py +429 -0
- {cyfer-0.2.2 → cyfer-0.2.3}/pyproject.toml +1 -1
- {cyfer-0.2.2 → cyfer-0.2.3}/LICENSE +0 -0
- {cyfer-0.2.2 → cyfer-0.2.3}/README.md +0 -0
- {cyfer-0.2.2 → cyfer-0.2.3}/cyfer/__init__.py +0 -0
- {cyfer-0.2.2 → cyfer-0.2.3}/cyfer/__main__.py +0 -0
- {cyfer-0.2.2 → cyfer-0.2.3}/cyfer/core/__init__.py +0 -0
- {cyfer-0.2.2 → cyfer-0.2.3}/cyfer/core/armor_binary.py +0 -0
- {cyfer-0.2.2 → cyfer-0.2.3}/cyfer/core/encrypt_decrypt.py +0 -0
- {cyfer-0.2.2 → cyfer-0.2.3}/cyfer/core/gpg_context.py +0 -0
- {cyfer-0.2.2 → cyfer-0.2.3}/cyfer/core/key_management.py +0 -0
- {cyfer-0.2.2 → cyfer-0.2.3}/cyfer/core/sign_verify.py +0 -0
- {cyfer-0.2.2 → cyfer-0.2.3}/cyfer/tui/__init__.py +0 -0
- {cyfer-0.2.2 → cyfer-0.2.3}/cyfer/tui/app.py +0 -0
- {cyfer-0.2.2 → cyfer-0.2.3}/cyfer/tui/modals/__init__.py +0 -0
- {cyfer-0.2.2 → cyfer-0.2.3}/cyfer/tui/screens/__init__.py +0 -0
- {cyfer-0.2.2 → cyfer-0.2.3}/cyfer/tui/settings.py +0 -0
- {cyfer-0.2.2 → cyfer-0.2.3}/cyfer/tui/widgets/__init__.py +0 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import Container, Horizontal, ScrollableContainer, Vertical
|
|
8
|
+
from textual.message import Message
|
|
9
|
+
from textual.screen import Screen
|
|
10
|
+
from textual.widgets import (
|
|
11
|
+
Button,
|
|
12
|
+
Footer,
|
|
13
|
+
Header,
|
|
14
|
+
Input,
|
|
15
|
+
Label,
|
|
16
|
+
Select,
|
|
17
|
+
Static,
|
|
18
|
+
Switch,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
from cyfer.tui.settings import AppSettings, CONFIG_FILE, save_settings
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_ALGO_OPTIONS: list[tuple[str, str]] = [
|
|
25
|
+
("RSA 4096", "rsa"),
|
|
26
|
+
("ECC (Ed25519 / Cv25519)", "ecc"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
_EXPIRE_OPTIONS: list[tuple[str, str]] = [
|
|
30
|
+
("Never", "0"),
|
|
31
|
+
("1 year", "1y"),
|
|
32
|
+
("2 years", "2y"),
|
|
33
|
+
("5 years", "5y"),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
_CACHE_WARNING = (
|
|
37
|
+
"⚠ SECURITY RISK ⚠\n"
|
|
38
|
+
"Passphrases will be held in application memory for the entire "
|
|
39
|
+
"session and reused automatically. They are never written to disk, "
|
|
40
|
+
"but any process that can read this application's memory - such as "
|
|
41
|
+
"a debugger, crash dump, or another process running as the same "
|
|
42
|
+
"user - could potentially recover them.\n"
|
|
43
|
+
"Enable only on a trusted, single-user system."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ConfigScreen(Screen):
|
|
48
|
+
TITLE = "Configuration"
|
|
49
|
+
|
|
50
|
+
DEFAULT_CSS = """
|
|
51
|
+
#cfg-scroll {
|
|
52
|
+
height: 1fr;
|
|
53
|
+
width: 100%;
|
|
54
|
+
padding: 1 3;
|
|
55
|
+
}
|
|
56
|
+
.cfg-section-label {
|
|
57
|
+
color: $primary;
|
|
58
|
+
text-style: bold;
|
|
59
|
+
margin-top: 1;
|
|
60
|
+
margin-bottom: 0;
|
|
61
|
+
padding-left: 0;
|
|
62
|
+
}
|
|
63
|
+
.cfg-divider {
|
|
64
|
+
color: $primary-darken-2;
|
|
65
|
+
margin-bottom: 1;
|
|
66
|
+
margin-top: 0;
|
|
67
|
+
}
|
|
68
|
+
.cfg-row {
|
|
69
|
+
height: auto;
|
|
70
|
+
width: 100%;
|
|
71
|
+
margin-bottom: 1;
|
|
72
|
+
align: left top;
|
|
73
|
+
}
|
|
74
|
+
.cfg-label {
|
|
75
|
+
width: 22;
|
|
76
|
+
color: $text-muted;
|
|
77
|
+
padding-top: 1;
|
|
78
|
+
}
|
|
79
|
+
.cfg-input-col {
|
|
80
|
+
width: 1fr;
|
|
81
|
+
height: auto;
|
|
82
|
+
}
|
|
83
|
+
.cfg-row Input {
|
|
84
|
+
width: 100%;
|
|
85
|
+
border: tall $primary-darken-1;
|
|
86
|
+
}
|
|
87
|
+
.cfg-row Input:focus {
|
|
88
|
+
border: tall $primary;
|
|
89
|
+
}
|
|
90
|
+
.cfg-row Select {
|
|
91
|
+
width: 1fr;
|
|
92
|
+
border: tall $primary-darken-1;
|
|
93
|
+
}
|
|
94
|
+
.cfg-row Select:focus {
|
|
95
|
+
border: tall $primary;
|
|
96
|
+
}
|
|
97
|
+
.cfg-status {
|
|
98
|
+
color: $text-muted;
|
|
99
|
+
text-style: italic;
|
|
100
|
+
height: auto;
|
|
101
|
+
padding-left: 0;
|
|
102
|
+
margin-top: 0;
|
|
103
|
+
}
|
|
104
|
+
.cfg-status-ok {
|
|
105
|
+
color: $success;
|
|
106
|
+
}
|
|
107
|
+
.cfg-status-err {
|
|
108
|
+
color: $error;
|
|
109
|
+
}
|
|
110
|
+
.cfg-about-value {
|
|
111
|
+
color: $text-muted;
|
|
112
|
+
padding-top: 0;
|
|
113
|
+
width: 1fr;
|
|
114
|
+
}
|
|
115
|
+
.cfg-spacer {
|
|
116
|
+
height: 1;
|
|
117
|
+
}
|
|
118
|
+
#cfg-buttons {
|
|
119
|
+
height: auto;
|
|
120
|
+
width: 100%;
|
|
121
|
+
align: left middle;
|
|
122
|
+
margin-bottom: 1;
|
|
123
|
+
}
|
|
124
|
+
#cfg-buttons Button {
|
|
125
|
+
margin-right: 1;
|
|
126
|
+
min-width: 12;
|
|
127
|
+
}
|
|
128
|
+
.cfg-switch-row {
|
|
129
|
+
align: left middle;
|
|
130
|
+
}
|
|
131
|
+
.cfg-switch-row .cfg-label {
|
|
132
|
+
padding-top: 0;
|
|
133
|
+
}
|
|
134
|
+
.cfg-cache-warning {
|
|
135
|
+
margin-top: 1;
|
|
136
|
+
margin-bottom: 1;
|
|
137
|
+
padding: 1 2;
|
|
138
|
+
border: tall $warning;
|
|
139
|
+
color: $warning;
|
|
140
|
+
text-style: bold;
|
|
141
|
+
background: $surface;
|
|
142
|
+
width: 100%;
|
|
143
|
+
height: auto;
|
|
144
|
+
}
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
BINDINGS = [
|
|
148
|
+
Binding("s", "save", "Save", show=True, priority=True),
|
|
149
|
+
Binding("d,q", "discard", "Discard", show=True),
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
class Saved(Message):
|
|
153
|
+
def __init__(self, settings: AppSettings) -> None:
|
|
154
|
+
super().__init__()
|
|
155
|
+
self.settings = settings
|
|
156
|
+
|
|
157
|
+
def __init__(self, current_settings: AppSettings, **kwargs) -> None:
|
|
158
|
+
super().__init__(**kwargs)
|
|
159
|
+
self._cfg = current_settings
|
|
160
|
+
|
|
161
|
+
def compose(self) -> ComposeResult:
|
|
162
|
+
yield Header()
|
|
163
|
+
|
|
164
|
+
with ScrollableContainer(id="cfg-scroll"):
|
|
165
|
+
|
|
166
|
+
yield Static("GPG ENVIRONMENT", classes="cfg-section-label")
|
|
167
|
+
yield Static("-" * 58, classes="cfg-divider")
|
|
168
|
+
|
|
169
|
+
with Horizontal(classes="cfg-row"):
|
|
170
|
+
yield Label("GNUPGHOME path", classes="cfg-label")
|
|
171
|
+
with Vertical(classes="cfg-input-col"):
|
|
172
|
+
yield Input(
|
|
173
|
+
value=self._cfg.gnupghome,
|
|
174
|
+
placeholder="leave blank to use the built-in test keyring",
|
|
175
|
+
id="cfg-gnupghome"
|
|
176
|
+
)
|
|
177
|
+
yield Static("", id="cfg-gnupghome-status", classes="cfg-status")
|
|
178
|
+
|
|
179
|
+
yield Static("GPG BINARY", classes="cfg-section-label")
|
|
180
|
+
yield Static("-" * 58, classes="cfg-divider")
|
|
181
|
+
|
|
182
|
+
with Horizontal(classes="cfg-row"):
|
|
183
|
+
yield Label("Binary name / path", classes="cfg-label")
|
|
184
|
+
with Vertical(classes="cfg-input-col"):
|
|
185
|
+
yield Input(
|
|
186
|
+
value=self._cfg.gpg_binary,
|
|
187
|
+
placeholder="gpg",
|
|
188
|
+
id="cfg-binary"
|
|
189
|
+
)
|
|
190
|
+
yield Static("", id="cfg-binary-status", classes="cfg-status")
|
|
191
|
+
|
|
192
|
+
yield Static("KEY GENERATION DEFAULTS", classes="cfg-section-label")
|
|
193
|
+
yield Static("-" * 58, classes="cfg-divider")
|
|
194
|
+
|
|
195
|
+
with Horizontal(classes="cfg-row"):
|
|
196
|
+
yield Label("Default algorithm", classes="cfg-label")
|
|
197
|
+
yield Select(
|
|
198
|
+
_ALGO_OPTIONS,
|
|
199
|
+
value=self._cfg.algorithm,
|
|
200
|
+
id="cfg-algo",
|
|
201
|
+
allow_blank=False
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
with Horizontal(classes="cfg-row"):
|
|
205
|
+
yield Label("Default expiry", classes="cfg-label")
|
|
206
|
+
yield Select(
|
|
207
|
+
_EXPIRE_OPTIONS,
|
|
208
|
+
value=self._cfg.expire,
|
|
209
|
+
id="cfg-expire",
|
|
210
|
+
allow_blank=False
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
yield Static("KEYSERVER", classes="cfg-section-label")
|
|
214
|
+
yield Static("-" * 58, classes="cfg-divider")
|
|
215
|
+
|
|
216
|
+
with Horizontal(classes="cfg-row"):
|
|
217
|
+
yield Label("Keyserver URL", classes="cfg-label")
|
|
218
|
+
with Vertical(classes="cfg-input-col"):
|
|
219
|
+
yield Input(
|
|
220
|
+
value=self._cfg.keyserver_url,
|
|
221
|
+
placeholder="https://keys.openpgp.org/",
|
|
222
|
+
id="cfg-keyserver"
|
|
223
|
+
)
|
|
224
|
+
yield Static("", id="cfg-keyserver-status", classes="cfg-status")
|
|
225
|
+
|
|
226
|
+
yield Static("SECURITY", classes="cfg-section-label")
|
|
227
|
+
yield Static("-" * 58, classes="cfg-divider")
|
|
228
|
+
|
|
229
|
+
with Horizontal(classes="cfg-row cfg-switch-row"):
|
|
230
|
+
yield Label("Passphrase caching", classes="cfg-label")
|
|
231
|
+
with Vertical(classes="cfg-input-col"):
|
|
232
|
+
yield Switch(
|
|
233
|
+
value=self._cfg.passphrase_cache_enabled,
|
|
234
|
+
id="cfg-cache-switch"
|
|
235
|
+
)
|
|
236
|
+
yield Static(
|
|
237
|
+
"Disabled by default. Read the warning below before enabling.",
|
|
238
|
+
id="cfg-cache-switch-hint",
|
|
239
|
+
classes="cfg-status"
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
yield Static(
|
|
243
|
+
_CACHE_WARNING,
|
|
244
|
+
id="cfg-cache-warning",
|
|
245
|
+
classes="cfg-cache-warning"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
yield Static("ABOUT", classes="cfg-section-label")
|
|
249
|
+
yield Static("-" * 58, classes="cfg-divider")
|
|
250
|
+
|
|
251
|
+
with Horizontal(classes="cfg-row"):
|
|
252
|
+
yield Label("Config file", classes="cfg-label")
|
|
253
|
+
yield Static(str(CONFIG_FILE), classes="cfg-about-value")
|
|
254
|
+
|
|
255
|
+
yield Static("", classes="cfg-spacer")
|
|
256
|
+
with Horizontal(id="cfg-buttons"):
|
|
257
|
+
yield Button("Save", variant="primary", id="cfg-btn-save")
|
|
258
|
+
yield Button("Discard", variant="default", id="cfg-btn-discard")
|
|
259
|
+
|
|
260
|
+
yield Footer()
|
|
261
|
+
|
|
262
|
+
def on_mount(self) -> None:
|
|
263
|
+
self.query_one("#cfg-gnupghome", Input).focus()
|
|
264
|
+
self._validate_gnupghome()
|
|
265
|
+
self._validate_binary()
|
|
266
|
+
self._validate_keyserver()
|
|
267
|
+
self._update_cache_warning()
|
|
268
|
+
|
|
269
|
+
def on_input_changed(self, event: Input.Changed) -> None:
|
|
270
|
+
if event.input.id == "cfg-gnupghome":
|
|
271
|
+
self._validate_gnupghome()
|
|
272
|
+
elif event.input.id == "cfg-binary":
|
|
273
|
+
self._validate_binary()
|
|
274
|
+
elif event.input.id == "cfg-keyserver":
|
|
275
|
+
self._validate_keyserver()
|
|
276
|
+
|
|
277
|
+
def on_switch_changed(self, event: Switch.Changed) -> None:
|
|
278
|
+
if event.switch.id == "cfg-cache-switch":
|
|
279
|
+
self._update_cache_warning()
|
|
280
|
+
|
|
281
|
+
def _update_cache_warning(self) -> None:
|
|
282
|
+
enabled = self.query_one("#cfg-cache-switch", Switch).value
|
|
283
|
+
warning = self.query_one("#cfg-cache-warning", Static)
|
|
284
|
+
hint = self.query_one("#cfg-cache-switch-hint", Static)
|
|
285
|
+
|
|
286
|
+
warning.display = enabled
|
|
287
|
+
|
|
288
|
+
if enabled:
|
|
289
|
+
hint.update("⚠ Enabled - passphrases cached in memory this session.")
|
|
290
|
+
hint.remove_class("cfg-status-ok")
|
|
291
|
+
hint.add_class("cfg-status-warn")
|
|
292
|
+
else:
|
|
293
|
+
hint.update("Disabled by default. Read the warning below before enabling.")
|
|
294
|
+
hint.remove_class("cfg-status-warn")
|
|
295
|
+
hint.add_class("cfg-status-ok")
|
|
296
|
+
|
|
297
|
+
def _validate_gnupghome(self) -> None:
|
|
298
|
+
raw = self.query_one("#cfg-gnupghome", Input).value.strip()
|
|
299
|
+
status = self.query_one("#cfg-gnupghome-status", Static)
|
|
300
|
+
|
|
301
|
+
if not raw:
|
|
302
|
+
self._ok(status, " → using built-in test keyring (cyfer-pgp library default)")
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
p = Path(raw).expanduser()
|
|
306
|
+
if p.exists():
|
|
307
|
+
if p.is_dir():
|
|
308
|
+
self._ok(status, f" ✔ exists: {p.resolve()}")
|
|
309
|
+
else:
|
|
310
|
+
self._err(status, f" ✗ path exists but is not a directory")
|
|
311
|
+
else:
|
|
312
|
+
self._ok(status, f" → will be created: {p.resolve()}")
|
|
313
|
+
|
|
314
|
+
def _validate_binary(self) -> None:
|
|
315
|
+
import shutil
|
|
316
|
+
raw = self.query_one("#cfg-binary", Input).value.strip() or "gpg"
|
|
317
|
+
status = self.query_one("#cfg-binary-status", Static)
|
|
318
|
+
|
|
319
|
+
found = shutil.which(raw)
|
|
320
|
+
if found:
|
|
321
|
+
version = self._read_gpg_version(found)
|
|
322
|
+
self._ok(status, f" ✔ {found}" + (f" ({version})" if version else ""))
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
p = Path(raw)
|
|
326
|
+
if p.exists():
|
|
327
|
+
import os
|
|
328
|
+
if os.access(p, os.X_OK):
|
|
329
|
+
self._ok(status, f" ✔ {p.resolve()}")
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
self._err(status, f" ✗ '{raw}' not found on PATH")
|
|
333
|
+
|
|
334
|
+
def _validate_keyserver(self) -> None:
|
|
335
|
+
raw = self.query_one("#cfg-keyserver", Input).value.strip()
|
|
336
|
+
status = self.query_one("#cfg-keyserver-status", Static)
|
|
337
|
+
|
|
338
|
+
if not raw:
|
|
339
|
+
self._ok(status, " → using default: https://keys.openpgp.org/")
|
|
340
|
+
return
|
|
341
|
+
|
|
342
|
+
if raw.startswith("https://") or raw.startswith("http://"):
|
|
343
|
+
self._ok(status, f" ✔ {raw}")
|
|
344
|
+
else:
|
|
345
|
+
self._err(status, " ✗ URL must start with 'https://' or 'http://'")
|
|
346
|
+
|
|
347
|
+
@staticmethod
|
|
348
|
+
def _ok(widget: Static, text: str) -> None:
|
|
349
|
+
widget.update(text)
|
|
350
|
+
widget.remove_class("cfg-status-err")
|
|
351
|
+
widget.remove_class("cfg-status-warn")
|
|
352
|
+
widget.add_class("cfg-status-ok")
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def _err(widget: Static, text: str) -> None:
|
|
356
|
+
widget.update(text)
|
|
357
|
+
widget.remove_class("cfg-status-ok")
|
|
358
|
+
widget.remove_class("cfg-status-warn")
|
|
359
|
+
widget.add_class("cfg-status-err")
|
|
360
|
+
|
|
361
|
+
@staticmethod
|
|
362
|
+
def _read_gpg_version(binary: str) -> str:
|
|
363
|
+
import subprocess
|
|
364
|
+
try:
|
|
365
|
+
result = subprocess.run(
|
|
366
|
+
[binary, "--version"],
|
|
367
|
+
capture_output=True,
|
|
368
|
+
timeout=2,
|
|
369
|
+
text=True
|
|
370
|
+
)
|
|
371
|
+
first = result.stdout.splitlines()[0] if result.stdout else ""
|
|
372
|
+
parts = first.split()
|
|
373
|
+
if len(parts) >= 3:
|
|
374
|
+
return f"{parts[1].strip('()')} {parts[2]}"
|
|
375
|
+
return first.strip()
|
|
376
|
+
|
|
377
|
+
except Exception:
|
|
378
|
+
return ""
|
|
379
|
+
|
|
380
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
381
|
+
if event.button.id == "cfg-btn-save":
|
|
382
|
+
self.action_save()
|
|
383
|
+
elif event.button.id == "cfg-btn-discard":
|
|
384
|
+
self.action_discard()
|
|
385
|
+
|
|
386
|
+
def action_save(self) -> None:
|
|
387
|
+
gnupghome = self.query_one("#cfg-gnupghome", Input).value.strip()
|
|
388
|
+
gpg_binary = self.query_one("#cfg-binary", Input).value.strip() or "gpg"
|
|
389
|
+
algorithm = str(self.query_one("#cfg-algo", Select).value)
|
|
390
|
+
expire = str(self.query_one("#cfg-expire", Select).value)
|
|
391
|
+
keyserver = self.query_one("#cfg-keyserver", Input).value.strip()
|
|
392
|
+
cache_on = self.query_one("#cfg-cache-switch", Switch).value
|
|
393
|
+
|
|
394
|
+
if keyserver and not (
|
|
395
|
+
keyserver.startswith("https://") or keyserver.startswith("http://")
|
|
396
|
+
):
|
|
397
|
+
self.notify(
|
|
398
|
+
"Keyserver URL must start with [b][u]https://[/u][/b] [i]or[/i] [b][u]http://[/u][/b]",
|
|
399
|
+
title="Invalid URL",
|
|
400
|
+
severity="warning",
|
|
401
|
+
markup=True
|
|
402
|
+
)
|
|
403
|
+
self.query_one("#cfg-keyserver", Input).focus()
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
new_cfg = AppSettings(
|
|
407
|
+
gnupghome=gnupghome,
|
|
408
|
+
gpg_binary=gpg_binary,
|
|
409
|
+
algorithm=algorithm,
|
|
410
|
+
expire=expire,
|
|
411
|
+
keyserver_url=keyserver or "https://keys.openpgp.org/",
|
|
412
|
+
passphrase_cache_enabled=cache_on
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
if not save_settings(new_cfg):
|
|
416
|
+
self.notify(
|
|
417
|
+
f"Could not write settings to:\n{CONFIG_FILE}\n"
|
|
418
|
+
"Check file permissions.",
|
|
419
|
+
title="Save failed",
|
|
420
|
+
severity="error",
|
|
421
|
+
timeout=8
|
|
422
|
+
)
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
self.app.post_message(self.Saved(new_cfg))
|
|
426
|
+
self.app.pop_screen()
|
|
427
|
+
|
|
428
|
+
def action_discard(self) -> None:
|
|
429
|
+
self.app.pop_screen()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|