basic-password-manager 0.4.0__tar.gz → 0.4.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.
Files changed (20) hide show
  1. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/PKG-INFO +1 -1
  2. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/basic_password_manager.egg-info/PKG-INFO +1 -1
  3. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/pw_manager/mobile.py +40 -15
  4. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/pw_manager/tui.py +96 -7
  5. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/pyproject.toml +1 -1
  6. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/LICENSE +0 -0
  7. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/README.md +0 -0
  8. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/basic_password_manager.egg-info/SOURCES.txt +0 -0
  9. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/basic_password_manager.egg-info/dependency_links.txt +0 -0
  10. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/basic_password_manager.egg-info/entry_points.txt +0 -0
  11. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/basic_password_manager.egg-info/requires.txt +0 -0
  12. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/basic_password_manager.egg-info/top_level.txt +0 -0
  13. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/pw_manager/__init__.py +0 -0
  14. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/pw_manager/cli.py +0 -0
  15. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/pw_manager/crypto.py +0 -0
  16. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/pw_manager/logo.py +0 -0
  17. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/pw_manager/vault.py +0 -0
  18. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/pw_manager/web/argon2.umd.min.js +0 -0
  19. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/pw_manager/web/page.html +0 -0
  20. {basic_password_manager-0.4.0 → basic_password_manager-0.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: basic-password-manager
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: The Paladin — a local encrypted password manager that lives in your terminal
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/Daisentaur/password-manager
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: basic-password-manager
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: The Paladin — a local encrypted password manager that lives in your terminal
5
5
  License-Expression: MIT
6
6
  Project-URL: Repository, https://github.com/Daisentaur/password-manager
@@ -162,9 +162,7 @@ def _build_page(raw_vault: bytes) -> bytes:
162
162
  ).encode()
163
163
 
164
164
 
165
- def _serve(vault_path: str, token: str, port: int = 0) -> http.server.HTTPServer:
166
- page_holder = {}
167
-
165
+ def _serve(vault_path: str, token: str, port: int = 0, on_fetch=None) -> http.server.HTTPServer:
168
166
  class Handler(http.server.BaseHTTPRequestHandler):
169
167
  def do_GET(self):
170
168
  if self.path.rstrip("/") != f"/{token}":
@@ -177,11 +175,14 @@ def _serve(vault_path: str, token: str, port: int = 0) -> http.server.HTTPServer
177
175
  self.send_header("Cache-Control", "no-store")
178
176
  self.end_headers()
179
177
  self.wfile.write(page)
178
+ if on_fetch:
179
+ on_fetch()
180
180
 
181
181
  def log_message(self, *a):
182
- pass # keep the terminal clean
182
+ pass # fetch visibility goes through on_fetch instead
183
183
 
184
- return http.server.HTTPServer(("127.0.0.1", port), Handler)
184
+ # threading server: a phone holding a connection open can't stall shutdown
185
+ return http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler)
185
186
 
186
187
 
187
188
  def _start_tunnel(cf: str, port: int) -> tuple[subprocess.Popen, str]:
@@ -204,7 +205,11 @@ def _start_tunnel(cf: str, port: int) -> tuple[subprocess.Popen, str]:
204
205
  raise MobileError("cloudflared didn't produce a tunnel URL in time")
205
206
 
206
207
 
207
- def run(vault_path: str, stable_url: str | None = None, port: int = 0) -> None:
208
+ def start_session(
209
+ vault_path: str, stable_url: str | None = None, port: int = 0, on_fetch=None
210
+ ) -> tuple[http.server.HTTPServer, subprocess.Popen | None, str]:
211
+ """Start server (+ tunnel unless a stable URL is configured). Returns
212
+ (server, tunnel_process_or_None, full_url)."""
208
213
  if not os.path.exists(vault_path):
209
214
  raise MobileError(f"no vault at {vault_path} — run 'paladin init' first")
210
215
 
@@ -214,7 +219,7 @@ def run(vault_path: str, stable_url: str | None = None, port: int = 0) -> None:
214
219
  port = int(os.environ.get("PALADIN_MOBILE_PORT", "8787"))
215
220
 
216
221
  token = _load_token()
217
- server = _serve(vault_path, token, port)
222
+ server = _serve(vault_path, token, port, on_fetch=on_fetch)
218
223
  port = server.server_address[1]
219
224
  threading.Thread(target=server.serve_forever, daemon=True).start()
220
225
 
@@ -222,10 +227,32 @@ def run(vault_path: str, stable_url: str | None = None, port: int = 0) -> None:
222
227
  if stable_url:
223
228
  base = stable_url.rstrip("/") # your reverse proxy points here → 127.0.0.1:port
224
229
  else:
225
- cf = cloudflared_path()
226
- proc, tunnel_url = _start_tunnel(cf, port)
227
- base = tunnel_url
228
- url = f"{base}/{token}"
230
+ try:
231
+ cf = cloudflared_path()
232
+ proc, base = _start_tunnel(cf, port)
233
+ except BaseException:
234
+ server.shutdown()
235
+ raise
236
+ return server, proc, f"{base}/{token}"
237
+
238
+
239
+ def stop_session(server, proc) -> None:
240
+ """Tunnel first (no new requests), then server; a second Ctrl+C during
241
+ cleanup must never produce a traceback."""
242
+ try:
243
+ if proc:
244
+ proc.terminate()
245
+ server.shutdown()
246
+ except KeyboardInterrupt:
247
+ pass
248
+
249
+
250
+ def run(vault_path: str, stable_url: str | None = None, port: int = 0) -> None:
251
+ def fetched():
252
+ print(f" vault page served to a device at {time.strftime('%H:%M:%S')}")
253
+
254
+ print("\n creating your secure link…", flush=True)
255
+ server, proc, url = start_session(vault_path, stable_url, port, on_fetch=fetched)
229
256
 
230
257
  import qrcode
231
258
 
@@ -235,7 +262,7 @@ def run(vault_path: str, stable_url: str | None = None, port: int = 0) -> None:
235
262
  qr.print_ascii(invert=True)
236
263
  print(f"\n {url}\n")
237
264
  print(" the master password is typed on your phone; only ciphertext leaves this machine.")
238
- print(" Ctrl+C to stop the session.\n")
265
+ print(" every page load is logged below — Ctrl+C to stop the session.\n")
239
266
 
240
267
  try:
241
268
  while True:
@@ -243,6 +270,4 @@ def run(vault_path: str, stable_url: str | None = None, port: int = 0) -> None:
243
270
  except KeyboardInterrupt:
244
271
  print("\nsession ended.")
245
272
  finally:
246
- server.shutdown()
247
- if proc:
248
- proc.terminate()
273
+ stop_session(server, proc)
@@ -7,7 +7,9 @@ and Textual's own built-in themes are available there too.
7
7
 
8
8
  import csv
9
9
  import os
10
+ import time
10
11
 
12
+ from rich.style import Style
11
13
  from rich.text import Text
12
14
  from textual.app import App, ComposeResult
13
15
  from textual.binding import Binding
@@ -158,6 +160,92 @@ class ImportModal(ModalScreen):
158
160
  self.dismiss(os.path.expanduser(event.value.strip()))
159
161
 
160
162
 
163
+ def _qr_text(url: str, primary: str) -> tuple[Text, int]:
164
+ """The QR as half-block art: theme-colored modules on white, darkened
165
+ until cameras can read it. Returns (text, width_in_cells) — the caller
166
+ must give the widget exactly that width: a wrapped QR is a dead QR."""
167
+ import qrcode
168
+
169
+ r, g, b = logo._hex_rgb(primary)
170
+ while (r + g + b) / 765 > 0.45:
171
+ r, g, b = int(r * 0.8), int(g * 0.8), int(b * 0.8)
172
+ dark, light = f"rgb({r},{g},{b})", "rgb(255,255,255)"
173
+
174
+ qr = qrcode.QRCode(border=2)
175
+ qr.add_data(url)
176
+ qr.make()
177
+ matrix = qr.get_matrix()
178
+ if len(matrix) % 2:
179
+ matrix.append([False] * len(matrix[0]))
180
+ text = Text(no_wrap=True)
181
+ for top, bottom in zip(matrix[::2], matrix[1::2]):
182
+ for t, btm in zip(top, bottom):
183
+ text.append("▀", Style(color=dark if t else light, bgcolor=dark if btm else light))
184
+ text.append("\n")
185
+ return text, len(matrix[0])
186
+
187
+
188
+ class MobileModal(ModalScreen):
189
+ """Serve the vault to a phone without leaving the TUI. The session lives
190
+ while this modal is open; Esc ends it."""
191
+
192
+ BINDINGS = [Binding("escape", "dismiss", "stop session")]
193
+
194
+ def __init__(self) -> None:
195
+ super().__init__()
196
+ self.server = None
197
+ self.proc = None
198
+
199
+ def compose(self) -> ComposeResult:
200
+ with Vertical(classes="modal-box", id="mobile-box"):
201
+ yield Label("open on phone", classes="modal-title")
202
+ yield Static("creating your secure link…", id="qr")
203
+ yield Static("", id="mobile-url")
204
+ yield Static("", id="mobile-status")
205
+ yield Static("esc ends the session", classes="modal-hint")
206
+
207
+ def on_mount(self) -> None:
208
+ self.run_worker(self._start, thread=True)
209
+
210
+ def _start(self) -> None:
211
+ from . import mobile
212
+
213
+ try:
214
+ self.server, self.proc, url = mobile.start_session(
215
+ VAULT_PATH, on_fetch=self._on_fetch
216
+ )
217
+ except Exception as e: # show any failure in the modal, don't die silently
218
+ self.app.call_from_thread(
219
+ self.query_one("#qr", Static).update, f"couldn't start: {e}"
220
+ )
221
+ return
222
+ self.app.call_from_thread(self._show_qr, url)
223
+
224
+ def _show_qr(self, url: str) -> None:
225
+ qr, width = _qr_text(url, self.app.current_theme.primary)
226
+ widget = self.query_one("#qr", Static)
227
+ widget.update(qr)
228
+ # pin every width to the QR's real size — auto-layout wrapping a QR
229
+ # scrambles the modules and kills scannability
230
+ widget.styles.width = width
231
+ for wid in ("#mobile-url", "#mobile-status"):
232
+ self.query_one(wid, Static).styles.width = width
233
+ self.query_one("#mobile-box").styles.width = width + 6 # padding + border
234
+ self.query_one("#mobile-url", Static).update(url)
235
+
236
+ def _on_fetch(self) -> None:
237
+ self.app.call_from_thread(
238
+ self.query_one("#mobile-status", Static).update,
239
+ f"vault page served to a device at {time.strftime('%H:%M:%S')}",
240
+ )
241
+
242
+ def on_unmount(self) -> None:
243
+ from . import mobile
244
+
245
+ if self.server:
246
+ mobile.stop_session(self.server, self.proc)
247
+
248
+
161
249
  class PasswdModal(ModalScreen):
162
250
  """Change the master password. Asks for the current one again so a
163
251
  walked-up-to unlocked session can't lock the real owner out."""
@@ -436,6 +524,13 @@ class PwApp(App):
436
524
  background: $surface;
437
525
  border: round $primary;
438
526
  }
527
+ #mobile-box {
528
+ width: auto;
529
+ }
530
+ #mobile-url {
531
+ color: $text-muted;
532
+ margin-top: 1;
533
+ }
439
534
  .modal-title {
440
535
  color: $primary;
441
536
  text-style: bold;
@@ -485,13 +580,7 @@ class PwApp(App):
485
580
  self.push_screen(ImportModal(), done)
486
581
 
487
582
  def _mobile(self) -> None:
488
- from . import mobile
489
-
490
- with self.suspend(): # hand the terminal to the QR + tunnel until Ctrl+C
491
- try:
492
- mobile.run(VAULT_PATH)
493
- except mobile.MobileError as e:
494
- input(f"\n{e}\n\npress Enter to return… ")
583
+ self.push_screen(MobileModal())
495
584
 
496
585
  def _change_master(self) -> None:
497
586
  def done(new: str | None) -> None:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "basic-password-manager"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "The Paladin — a local encrypted password manager that lives in your terminal"
9
9
  readme = "README.md"
10
10
  license = "MIT"