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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cyfer
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: A package for doing great things!
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -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()
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "cyfer"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "A package for doing great things!"
5
5
  authors = ["Dylan Garrett"]
6
6
  license = "MIT"
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