basic-password-manager 0.4.4__tar.gz → 0.4.6__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.4 → basic_password_manager-0.4.6}/PKG-INFO +21 -6
  2. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/README.md +20 -5
  3. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/basic_password_manager.egg-info/PKG-INFO +21 -6
  4. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/pw_manager/cli.py +25 -0
  5. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/pw_manager/logo.py +11 -2
  6. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/pw_manager/mobile.py +117 -10
  7. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/pw_manager/tui.py +18 -3
  8. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/pyproject.toml +1 -1
  9. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/LICENSE +0 -0
  10. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/basic_password_manager.egg-info/SOURCES.txt +0 -0
  11. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/basic_password_manager.egg-info/dependency_links.txt +0 -0
  12. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/basic_password_manager.egg-info/entry_points.txt +0 -0
  13. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/basic_password_manager.egg-info/requires.txt +0 -0
  14. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/basic_password_manager.egg-info/top_level.txt +0 -0
  15. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/pw_manager/__init__.py +0 -0
  16. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/pw_manager/crypto.py +0 -0
  17. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/pw_manager/vault.py +0 -0
  18. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/pw_manager/web/argon2.umd.min.js +0 -0
  19. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/pw_manager/web/page.html +0 -0
  20. {basic_password_manager-0.4.4 → basic_password_manager-0.4.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: basic-password-manager
3
- Version: 0.4.4
3
+ Version: 0.4.6
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
@@ -102,6 +102,7 @@ Same vault, no interface, for when you just want the thing:
102
102
  | `paladin gen -l 32` | just print a random password |
103
103
  | `paladin import passwords.csv` | import a browser CSV export (see below) |
104
104
  | `paladin mobile` | open your vault on your phone (QR + HTTPS tunnel) |
105
+ | `paladin mobile -b` | same, but keeps running after you close the terminal |
105
106
  | `paladin about` | meet the knight |
106
107
 
107
108
  The vault lives at `~/.local/share/pw-manager/vault`. Point the `PW_VAULT`
@@ -154,11 +155,25 @@ use (reverse proxy, named tunnel — `PALADIN_MOBILE_PORT` changes the port).
154
155
  From then on plain `paladin mobile` serves at your subdomain, every time, so
155
156
  a phone bookmark keeps working.
156
157
 
157
- To be clear about what this does and doesn't change: a session still only
158
- lives while `paladin mobile` is running — closing it still ends access, and
159
- the secret path at the end of the URL still expires every 30 days (your
160
- bookmark needs refreshing then, on purpose). The only difference is that
161
- every new session opens at *your* address instead of a random one.
158
+ Every new session opens at *your* address instead of a random one, and the
159
+ secret path at the end of the URL still expires every 30 days (your bookmark
160
+ needs refreshing then, on purpose).
161
+
162
+ ### Keeping it up after you close the terminal
163
+
164
+ By default a session ends when you press Ctrl+C — fine for a quick lookup,
165
+ useless if you want the bookmark to work while you're out. So:
166
+
167
+ ```bash
168
+ paladin mobile -b # background: survives closing the terminal
169
+ paladin mobile --status # what's running, and when the link expires
170
+ paladin mobile --stop # end it now
171
+ ```
172
+
173
+ A background session keeps serving until you stop it or the link hits its
174
+ 30-day expiry, at which point it shuts itself down rather than lingering
175
+ forever. What's exposed the whole time is the same encrypted blob as always
176
+ — useless without the master password — behind a random 128-bit path.
162
177
 
163
178
  The one thing crossing the internet is the encrypted blob, which is useless
164
179
  without the master password that never leaves your phone. The tunnel provider
@@ -86,6 +86,7 @@ Same vault, no interface, for when you just want the thing:
86
86
  | `paladin gen -l 32` | just print a random password |
87
87
  | `paladin import passwords.csv` | import a browser CSV export (see below) |
88
88
  | `paladin mobile` | open your vault on your phone (QR + HTTPS tunnel) |
89
+ | `paladin mobile -b` | same, but keeps running after you close the terminal |
89
90
  | `paladin about` | meet the knight |
90
91
 
91
92
  The vault lives at `~/.local/share/pw-manager/vault`. Point the `PW_VAULT`
@@ -138,11 +139,25 @@ use (reverse proxy, named tunnel — `PALADIN_MOBILE_PORT` changes the port).
138
139
  From then on plain `paladin mobile` serves at your subdomain, every time, so
139
140
  a phone bookmark keeps working.
140
141
 
141
- To be clear about what this does and doesn't change: a session still only
142
- lives while `paladin mobile` is running — closing it still ends access, and
143
- the secret path at the end of the URL still expires every 30 days (your
144
- bookmark needs refreshing then, on purpose). The only difference is that
145
- every new session opens at *your* address instead of a random one.
142
+ Every new session opens at *your* address instead of a random one, and the
143
+ secret path at the end of the URL still expires every 30 days (your bookmark
144
+ needs refreshing then, on purpose).
145
+
146
+ ### Keeping it up after you close the terminal
147
+
148
+ By default a session ends when you press Ctrl+C — fine for a quick lookup,
149
+ useless if you want the bookmark to work while you're out. So:
150
+
151
+ ```bash
152
+ paladin mobile -b # background: survives closing the terminal
153
+ paladin mobile --status # what's running, and when the link expires
154
+ paladin mobile --stop # end it now
155
+ ```
156
+
157
+ A background session keeps serving until you stop it or the link hits its
158
+ 30-day expiry, at which point it shuts itself down rather than lingering
159
+ forever. What's exposed the whole time is the same encrypted blob as always
160
+ — useless without the master password — behind a random 128-bit path.
146
161
 
147
162
  The one thing crossing the internet is the encrypted blob, which is useless
148
163
  without the master password that never leaves your phone. The tunnel provider
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: basic-password-manager
3
- Version: 0.4.4
3
+ Version: 0.4.6
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
@@ -102,6 +102,7 @@ Same vault, no interface, for when you just want the thing:
102
102
  | `paladin gen -l 32` | just print a random password |
103
103
  | `paladin import passwords.csv` | import a browser CSV export (see below) |
104
104
  | `paladin mobile` | open your vault on your phone (QR + HTTPS tunnel) |
105
+ | `paladin mobile -b` | same, but keeps running after you close the terminal |
105
106
  | `paladin about` | meet the knight |
106
107
 
107
108
  The vault lives at `~/.local/share/pw-manager/vault`. Point the `PW_VAULT`
@@ -154,11 +155,25 @@ use (reverse proxy, named tunnel — `PALADIN_MOBILE_PORT` changes the port).
154
155
  From then on plain `paladin mobile` serves at your subdomain, every time, so
155
156
  a phone bookmark keeps working.
156
157
 
157
- To be clear about what this does and doesn't change: a session still only
158
- lives while `paladin mobile` is running — closing it still ends access, and
159
- the secret path at the end of the URL still expires every 30 days (your
160
- bookmark needs refreshing then, on purpose). The only difference is that
161
- every new session opens at *your* address instead of a random one.
158
+ Every new session opens at *your* address instead of a random one, and the
159
+ secret path at the end of the URL still expires every 30 days (your bookmark
160
+ needs refreshing then, on purpose).
161
+
162
+ ### Keeping it up after you close the terminal
163
+
164
+ By default a session ends when you press Ctrl+C — fine for a quick lookup,
165
+ useless if you want the bookmark to work while you're out. So:
166
+
167
+ ```bash
168
+ paladin mobile -b # background: survives closing the terminal
169
+ paladin mobile --status # what's running, and when the link expires
170
+ paladin mobile --stop # end it now
171
+ ```
172
+
173
+ A background session keeps serving until you stop it or the link hits its
174
+ 30-day expiry, at which point it shuts itself down rather than lingering
175
+ forever. What's exposed the whole time is the same encrypted blob as always
176
+ — useless without the master password — behind a random 128-bit path.
162
177
 
163
178
  The one thing crossing the internet is the encrypted blob, which is useless
164
179
  without the master password that never leaves your phone. The tunnel provider
@@ -64,9 +64,28 @@ def cmd_init(args):
64
64
 
65
65
 
66
66
  def cmd_mobile(args):
67
+ import time
68
+
67
69
  from . import mobile
68
70
 
69
71
  try:
72
+ if args.stop:
73
+ print("background session stopped" if mobile.stop_daemon() else "nothing running")
74
+ return
75
+ if args.status:
76
+ state = mobile.read_state()
77
+ if not state:
78
+ print("no background session running")
79
+ return
80
+ left = (state["expires"] - time.time()) / 86400
81
+ print(f"serving {state['url']}\nlink expires in {left:.1f} days (pid {state['pid']})")
82
+ return
83
+ if args.background:
84
+ url = mobile.start_daemon(VAULT_PATH, stable_url=args.url)
85
+ mobile.print_qr(url)
86
+ print(" running in the background — it survives closing this terminal.")
87
+ print(" 'paladin mobile --status' to check, '--stop' to end it.\n")
88
+ return
70
89
  mobile.run(VAULT_PATH, stable_url=args.url)
71
90
  except mobile.MobileError as e:
72
91
  sys.exit(str(e))
@@ -237,6 +256,12 @@ def main():
237
256
  i.add_argument("csv_file")
238
257
  m = sub.add_parser("mobile", help="open your vault on your phone over an HTTPS tunnel")
239
258
  m.add_argument("--url", help="serve at your own https subdomain instead of a quick tunnel")
259
+ m.add_argument(
260
+ "-b", "--background", action="store_true",
261
+ help="keep serving after this terminal closes (until the link expires)",
262
+ )
263
+ m.add_argument("--status", action="store_true", help="is a background session running?")
264
+ m.add_argument("--stop", action="store_true", help="stop the background session")
240
265
  sub.add_parser("about", help="meet the knight")
241
266
 
242
267
  args = p.parse_args()
@@ -75,9 +75,18 @@ def _tinted(pixel, tint, accent):
75
75
  return tuple(int(c * lum) for c in base)
76
76
 
77
77
 
78
- def _hex_rgb(color: str) -> tuple:
78
+ def _hex_rgb(color: str | None) -> tuple | None:
79
+ """Parse #rrggbb to (r,g,b). Returns None for anything that isn't hex —
80
+ ANSI themes use names like 'ansi_blue', and those just mean 'no tint'."""
81
+ if not color:
82
+ return None
79
83
  color = color.lstrip("#")
80
- return tuple(int(color[i : i + 2], 16) for i in (0, 2, 4))
84
+ if len(color) != 6:
85
+ return None
86
+ try:
87
+ return tuple(int(color[i : i + 2], 16) for i in (0, 2, 4))
88
+ except ValueError:
89
+ return None
81
90
 
82
91
 
83
92
  def rich_text(tint: str | None = None, accent: str | None = None) -> Text:
@@ -9,11 +9,13 @@ and the network only ever carry ciphertext.
9
9
  import base64
10
10
  import hashlib
11
11
  import http.server
12
+ import json
12
13
  import os
13
14
  import platform
14
15
  import re
15
16
  import secrets
16
17
  import shutil
18
+ import signal
17
19
  import stat
18
20
  import subprocess
19
21
  import sys
@@ -113,6 +115,81 @@ def _load_token() -> str:
113
115
  return token
114
116
 
115
117
 
118
+ def token_expires_at() -> float:
119
+ """Unix time when the current link stops working."""
120
+ try:
121
+ created, _ = open(os.path.join(_data_dir(), "mobile-token")).read().split(":", 1)
122
+ return float(created) + TOKEN_TTL
123
+ except (OSError, ValueError):
124
+ return time.time() + TOKEN_TTL
125
+
126
+
127
+ def _state_path() -> str:
128
+ return os.path.join(_data_dir(), "mobile-session.json")
129
+
130
+
131
+ def read_state() -> dict | None:
132
+ """The running background session, or None if there isn't a live one."""
133
+ try:
134
+ with open(_state_path()) as f:
135
+ state = json.load(f)
136
+ os.kill(state["pid"], 0) # raises if that process is gone
137
+ return state
138
+ except (OSError, ValueError, KeyError):
139
+ return None
140
+
141
+
142
+ def stop_daemon() -> bool:
143
+ """Stop a background session. True if one was running."""
144
+ state = read_state()
145
+ if not state:
146
+ return False
147
+ try:
148
+ os.kill(state["pid"], signal.SIGTERM)
149
+ except OSError:
150
+ pass
151
+ try:
152
+ os.remove(_state_path())
153
+ except OSError:
154
+ pass
155
+ return True
156
+
157
+
158
+ def start_daemon(vault_path: str, stable_url: str | None = None, port: int = 0) -> str:
159
+ """Re-launch ourselves detached so the session outlives this shell.
160
+ Returns the URL the background session is serving."""
161
+ if read_state():
162
+ raise MobileError("a background session is already running — 'paladin mobile --stop' first")
163
+ if not os.path.exists(vault_path):
164
+ raise MobileError(f"no vault at {vault_path} — run 'paladin init' first")
165
+
166
+ log = os.path.join(_data_dir(), "mobile.log")
167
+ cmd = [sys.executable, "-m", "pw_manager.cli", "mobile"]
168
+ if stable_url:
169
+ cmd += ["--url", stable_url]
170
+ env = {**os.environ, "PALADIN_MOBILE_DAEMON_CHILD": "1"}
171
+ with open(log, "ab") as logfile:
172
+ proc = subprocess.Popen(
173
+ cmd,
174
+ stdout=logfile,
175
+ stderr=logfile,
176
+ stdin=subprocess.DEVNULL,
177
+ start_new_session=True, # survives this shell closing
178
+ env=env,
179
+ )
180
+ # wait for the child to publish its URL (tunnel start can take seconds)
181
+ deadline = time.time() + 45
182
+ while time.time() < deadline:
183
+ state = read_state()
184
+ if state and state.get("pid") == proc.pid:
185
+ return state["url"]
186
+ if proc.poll() is not None:
187
+ raise MobileError(f"background session failed to start — see {log}")
188
+ time.sleep(0.3)
189
+ proc.terminate()
190
+ raise MobileError(f"background session didn't come up in time — see {log}")
191
+
192
+
116
193
  def _theme_colors() -> dict:
117
194
  """Resolve the theme saved by the TUI to a full color palette. The page
118
195
  is baked with whatever theme is current when the QR is generated."""
@@ -130,6 +207,10 @@ def _theme_colors() -> dict:
130
207
  registry = {t.name: t for t in TUXEDO_THEMES}
131
208
  registry.update(BUILTIN_THEMES)
132
209
  cs = registry[name].to_color_system().generate()
210
+ # ANSI themes resolve to names like 'ansi_blue', not hex — the browser
211
+ # can't use those and the knight can't tint from them, so fall back
212
+ if not str(cs.get("background", "")).startswith("#"):
213
+ return default
133
214
  return {**default, **cs}
134
215
  except (OSError, KeyError, ImportError):
135
216
  return default
@@ -247,13 +328,7 @@ def stop_session(server, proc) -> None:
247
328
  pass
248
329
 
249
330
 
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)
256
-
331
+ def print_qr(url: str) -> None:
257
332
  import qrcode
258
333
 
259
334
  qr = qrcode.QRCode(border=1)
@@ -261,13 +336,45 @@ def run(vault_path: str, stable_url: str | None = None, port: int = 0) -> None:
261
336
  print("\n scan with your phone camera:\n")
262
337
  qr.print_ascii(invert=True)
263
338
  print(f"\n {url}\n")
264
- print(" the master password is typed on your phone; only ciphertext leaves this machine.")
265
- print(" every page load is logged below — Ctrl+C to stop the session.\n")
339
+
340
+
341
+ def run(vault_path: str, stable_url: str | None = None, port: int = 0) -> None:
342
+ is_child = os.environ.get("PALADIN_MOBILE_DAEMON_CHILD") == "1"
343
+
344
+ def fetched():
345
+ print(f" vault page served to a device at {time.strftime('%H:%M:%S')}", flush=True)
346
+
347
+ print("\n creating your secure link…", flush=True)
348
+ server, proc, url = start_session(vault_path, stable_url, port, on_fetch=fetched)
349
+
350
+ if proc is None: # stable mode: serving your own address, no quick tunnel
351
+ print(
352
+ f" serving your configured address on 127.0.0.1:{server.server_address[1]}"
353
+ " — your tunnel/proxy must forward to this port"
354
+ )
355
+ else:
356
+ print(" using a temporary tunnel (set PALADIN_MOBILE_URL for your own subdomain)")
357
+
358
+ expires = token_expires_at()
359
+ if is_child: # background session: publish state so --status/--stop can find us
360
+ with open(_state_path(), "w") as f:
361
+ json.dump({"pid": os.getpid(), "url": url, "expires": expires}, f)
362
+ signal.signal(signal.SIGTERM, lambda *a: (_ for _ in ()).throw(KeyboardInterrupt))
363
+ else:
364
+ print_qr(url)
365
+ print(" the master password is typed on your phone; only ciphertext leaves this machine.")
366
+ print(" every page load is logged below — Ctrl+C to stop the session.\n")
266
367
 
267
368
  try:
268
- while True:
369
+ while time.time() < expires: # the link's 30 days, enforced for real
269
370
  time.sleep(1)
371
+ print("\nlink expired after 30 days — run 'paladin mobile' again for a fresh one.")
270
372
  except KeyboardInterrupt:
271
373
  print("\nsession ended.")
272
374
  finally:
273
375
  stop_session(server, proc)
376
+ if is_child:
377
+ try:
378
+ os.remove(_state_path())
379
+ except OSError:
380
+ pass
@@ -11,7 +11,7 @@ import time
11
11
 
12
12
  from rich.style import Style
13
13
  from rich.text import Text
14
- from textual.app import App, ComposeResult
14
+ from textual.app import App, ComposeResult, SystemCommand
15
15
  from textual.binding import Binding
16
16
  from textual.command import DiscoveryHit, Hit, Hits, Provider
17
17
  from textual.containers import Vertical
@@ -103,6 +103,8 @@ class UnlockScreen(Screen):
103
103
  self.query_one("#master", Input).clear()
104
104
  return
105
105
  self.app.master = event.value
106
+ # stop repainting the (now-hidden) unlock knight on every theme change
107
+ self.app.theme_changed_signal.unsubscribe(self)
106
108
  self.app.push_screen(MainScreen())
107
109
 
108
110
 
@@ -167,7 +169,8 @@ def _qr_text(url: str, primary: str) -> tuple[Text, int]:
167
169
  must give the widget exactly that width: a wrapped QR is a dead QR."""
168
170
  import qrcode
169
171
 
170
- r, g, b = logo._hex_rgb(primary)
172
+ rgb = logo._hex_rgb(primary) or (0, 0, 0) # ansi themes → plain black QR
173
+ r, g, b = rgb
171
174
  while (r + g + b) / 765 > 0.45:
172
175
  r, g, b = int(r * 0.8), int(g * 0.8), int(b * 0.8)
173
176
  dark, light = f"rgb({r},{g},{b})", "rgb(255,255,255)"
@@ -473,7 +476,7 @@ class MainScreen(Screen):
473
476
  self.app.push_screen(ConfirmModal(f"delete '{name}'?"), done)
474
477
 
475
478
  def action_theme(self) -> None:
476
- self.app.push_screen(ThemeModal())
479
+ self.app._theme() # same picker the palette opens
477
480
 
478
481
  def action_search(self) -> None:
479
482
  self.query_one("#search", Input).focus()
@@ -609,6 +612,18 @@ class PwApp(App):
609
612
  with open(THEME_FILE, "w") as f:
610
613
  f.write(theme.name)
611
614
 
615
+ def get_system_commands(self, screen: Screen):
616
+ """Swap Textual's built-in 'Theme' entry for our picker, so the
617
+ palette and the `t` key open the same thing."""
618
+ for command in super().get_system_commands(screen):
619
+ if command.title == "Theme":
620
+ yield SystemCommand("Theme", "Change the current theme", self._theme)
621
+ else:
622
+ yield command
623
+
624
+ def _theme(self) -> None:
625
+ self.push_screen(ThemeModal())
626
+
612
627
  def _import_csv(self) -> None:
613
628
  def done(path: str | None) -> None:
614
629
  if not path:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "basic-password-manager"
7
- version = "0.4.4"
7
+ version = "0.4.6"
8
8
  description = "The Paladin — a local encrypted password manager that lives in your terminal"
9
9
  readme = "README.md"
10
10
  license = "MIT"