basic-password-manager 0.4.0__tar.gz → 0.4.1__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.1}/PKG-INFO +1 -1
  2. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/basic_password_manager.egg-info/PKG-INFO +1 -1
  3. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/pw_manager/mobile.py +40 -15
  4. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/pw_manager/tui.py +81 -7
  5. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/pyproject.toml +1 -1
  6. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/LICENSE +0 -0
  7. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/README.md +0 -0
  8. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/basic_password_manager.egg-info/SOURCES.txt +0 -0
  9. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/basic_password_manager.egg-info/dependency_links.txt +0 -0
  10. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/basic_password_manager.egg-info/entry_points.txt +0 -0
  11. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/basic_password_manager.egg-info/requires.txt +0 -0
  12. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/basic_password_manager.egg-info/top_level.txt +0 -0
  13. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/pw_manager/__init__.py +0 -0
  14. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/pw_manager/cli.py +0 -0
  15. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/pw_manager/crypto.py +0 -0
  16. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/pw_manager/logo.py +0 -0
  17. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/pw_manager/vault.py +0 -0
  18. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/pw_manager/web/argon2.umd.min.js +0 -0
  19. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/pw_manager/web/page.html +0 -0
  20. {basic_password_manager-0.4.0 → basic_password_manager-0.4.1}/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.1
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.1
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,80 @@ 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) -> Text:
164
+ """The QR as half-block art: theme-colored modules on white, darkened
165
+ until cameras can read it (contrast beats style, but we can have both)."""
166
+ import qrcode
167
+
168
+ r, g, b = logo._hex_rgb(primary)
169
+ while (r + g + b) / 765 > 0.45:
170
+ r, g, b = int(r * 0.8), int(g * 0.8), int(b * 0.8)
171
+ dark, light = f"rgb({r},{g},{b})", "rgb(255,255,255)"
172
+
173
+ qr = qrcode.QRCode(border=2)
174
+ qr.add_data(url)
175
+ qr.make()
176
+ matrix = qr.get_matrix()
177
+ if len(matrix) % 2:
178
+ matrix.append([False] * len(matrix[0]))
179
+ text = Text()
180
+ for top, bottom in zip(matrix[::2], matrix[1::2]):
181
+ for t, btm in zip(top, bottom):
182
+ text.append("▀", Style(color=dark if t else light, bgcolor=dark if btm else light))
183
+ text.append("\n")
184
+ return text
185
+
186
+
187
+ class MobileModal(ModalScreen):
188
+ """Serve the vault to a phone without leaving the TUI. The session lives
189
+ while this modal is open; Esc ends it."""
190
+
191
+ BINDINGS = [Binding("escape", "dismiss", "stop session")]
192
+
193
+ def __init__(self) -> None:
194
+ super().__init__()
195
+ self.server = None
196
+ self.proc = None
197
+
198
+ def compose(self) -> ComposeResult:
199
+ with Vertical(classes="modal-box", id="mobile-box"):
200
+ yield Label("open on phone", classes="modal-title")
201
+ yield Static("creating your secure link…", id="qr")
202
+ yield Static("", id="mobile-status")
203
+ yield Static("esc ends the session", classes="modal-hint")
204
+
205
+ def on_mount(self) -> None:
206
+ self.run_worker(self._start, thread=True)
207
+
208
+ def _start(self) -> None:
209
+ from . import mobile
210
+
211
+ try:
212
+ self.server, self.proc, url = mobile.start_session(
213
+ VAULT_PATH, on_fetch=self._on_fetch
214
+ )
215
+ except Exception as e: # show any failure in the modal, don't die silently
216
+ self.app.call_from_thread(
217
+ self.query_one("#qr", Static).update, f"couldn't start: {e}"
218
+ )
219
+ return
220
+ qr = _qr_text(url, self.app.current_theme.primary)
221
+ qr.append(f"\n{url}", Style(color=self.app.current_theme.primary))
222
+ self.app.call_from_thread(self.query_one("#qr", Static).update, qr)
223
+
224
+ def _on_fetch(self) -> None:
225
+ self.app.call_from_thread(
226
+ self.query_one("#mobile-status", Static).update,
227
+ f"vault page served to a device at {time.strftime('%H:%M:%S')}",
228
+ )
229
+
230
+ def on_unmount(self) -> None:
231
+ from . import mobile
232
+
233
+ if self.server:
234
+ mobile.stop_session(self.server, self.proc)
235
+
236
+
161
237
  class PasswdModal(ModalScreen):
162
238
  """Change the master password. Asks for the current one again so a
163
239
  walked-up-to unlocked session can't lock the real owner out."""
@@ -436,6 +512,10 @@ class PwApp(App):
436
512
  background: $surface;
437
513
  border: round $primary;
438
514
  }
515
+ #mobile-box {
516
+ width: auto;
517
+ max-width: 90%;
518
+ }
439
519
  .modal-title {
440
520
  color: $primary;
441
521
  text-style: bold;
@@ -485,13 +565,7 @@ class PwApp(App):
485
565
  self.push_screen(ImportModal(), done)
486
566
 
487
567
  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… ")
568
+ self.push_screen(MobileModal())
495
569
 
496
570
  def _change_master(self) -> None:
497
571
  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.1"
8
8
  description = "The Paladin — a local encrypted password manager that lives in your terminal"
9
9
  readme = "README.md"
10
10
  license = "MIT"