basic-password-manager 0.4.5__tar.gz → 0.4.7__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.5 → basic_password_manager-0.4.7}/PKG-INFO +30 -10
  2. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/README.md +29 -9
  3. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/basic_password_manager.egg-info/PKG-INFO +30 -10
  4. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/pw_manager/cli.py +99 -13
  5. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/pw_manager/mobile.py +113 -10
  6. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/pw_manager/tui.py +17 -4
  7. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/pyproject.toml +1 -1
  8. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/LICENSE +0 -0
  9. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/basic_password_manager.egg-info/SOURCES.txt +0 -0
  10. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/basic_password_manager.egg-info/dependency_links.txt +0 -0
  11. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/basic_password_manager.egg-info/entry_points.txt +0 -0
  12. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/basic_password_manager.egg-info/requires.txt +0 -0
  13. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/basic_password_manager.egg-info/top_level.txt +0 -0
  14. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/pw_manager/__init__.py +0 -0
  15. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/pw_manager/crypto.py +0 -0
  16. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/pw_manager/logo.py +0 -0
  17. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/pw_manager/vault.py +0 -0
  18. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/pw_manager/web/argon2.umd.min.js +0 -0
  19. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/pw_manager/web/page.html +0 -0
  20. {basic_password_manager-0.4.5 → basic_password_manager-0.4.7}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: basic-password-manager
3
- Version: 0.4.5
3
+ Version: 0.4.7
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
@@ -211,8 +226,11 @@ Both are thin skins over the two modules above. The TUI (built with
211
226
  [Textual](https://textual.textualize.io)) holds the decrypted entries in
212
227
  memory for the session; the CLI re-derives the key per command.
213
228
 
214
- Passwords go to the clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists)
215
- rather than the screen, so they don't sit in your terminal scrollback. One
229
+ Passwords go to the clipboard (`wl-copy`/`xclip`/`xsel`/`pbcopy`/`clip`,
230
+ whichever exists) rather than the screen, so they don't sit in your terminal
231
+ scrollback — and they're wiped from it 45 seconds later by a detached helper
232
+ (the CLI exits immediately, so an in-process timer would never fire). If
233
+ you've copied something else in the meantime, yours is left alone. One
216
234
  deliberate asymmetry: CLI `find` does *not* search passwords, because a CLI
217
235
  argument lands in your shell history forever — the TUI search box does,
218
236
  because nothing you type there is logged anywhere.
@@ -283,8 +301,10 @@ accounts from 2013. `paladin rm <name>` the corpses, or prune the CSV first.
283
301
 
284
302
  I'd rather you know these than find them:
285
303
 
286
- - No clipboard auto-clear yet: a copied password stays on the clipboard
287
- until you copy over it. Copy something else when you're done.
304
+ - The 45-second clipboard window is still a window: anything watching your
305
+ clipboard in that time sees the password. Set
306
+ `PALADIN_CLIPBOARD_SECONDS` lower if that bothers you (or `0` to keep the
307
+ old forever-behaviour).
288
308
  - While a command runs, decrypted data briefly exists in process memory.
289
309
  Malware already on your machine could read it — true of every password
290
310
  manager ever made; the vault protects the file at rest, not a compromised
@@ -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
@@ -195,8 +210,11 @@ Both are thin skins over the two modules above. The TUI (built with
195
210
  [Textual](https://textual.textualize.io)) holds the decrypted entries in
196
211
  memory for the session; the CLI re-derives the key per command.
197
212
 
198
- Passwords go to the clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists)
199
- rather than the screen, so they don't sit in your terminal scrollback. One
213
+ Passwords go to the clipboard (`wl-copy`/`xclip`/`xsel`/`pbcopy`/`clip`,
214
+ whichever exists) rather than the screen, so they don't sit in your terminal
215
+ scrollback — and they're wiped from it 45 seconds later by a detached helper
216
+ (the CLI exits immediately, so an in-process timer would never fire). If
217
+ you've copied something else in the meantime, yours is left alone. One
200
218
  deliberate asymmetry: CLI `find` does *not* search passwords, because a CLI
201
219
  argument lands in your shell history forever — the TUI search box does,
202
220
  because nothing you type there is logged anywhere.
@@ -267,8 +285,10 @@ accounts from 2013. `paladin rm <name>` the corpses, or prune the CSV first.
267
285
 
268
286
  I'd rather you know these than find them:
269
287
 
270
- - No clipboard auto-clear yet: a copied password stays on the clipboard
271
- until you copy over it. Copy something else when you're done.
288
+ - The 45-second clipboard window is still a window: anything watching your
289
+ clipboard in that time sees the password. Set
290
+ `PALADIN_CLIPBOARD_SECONDS` lower if that bothers you (or `0` to keep the
291
+ old forever-behaviour).
272
292
  - While a command runs, decrypted data briefly exists in process memory.
273
293
  Malware already on your machine could read it — true of every password
274
294
  manager ever made; the vault protects the file at rest, not a compromised
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: basic-password-manager
3
- Version: 0.4.5
3
+ Version: 0.4.7
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
@@ -211,8 +226,11 @@ Both are thin skins over the two modules above. The TUI (built with
211
226
  [Textual](https://textual.textualize.io)) holds the decrypted entries in
212
227
  memory for the session; the CLI re-derives the key per command.
213
228
 
214
- Passwords go to the clipboard (`wl-copy`/`xclip`/`xsel`, whichever exists)
215
- rather than the screen, so they don't sit in your terminal scrollback. One
229
+ Passwords go to the clipboard (`wl-copy`/`xclip`/`xsel`/`pbcopy`/`clip`,
230
+ whichever exists) rather than the screen, so they don't sit in your terminal
231
+ scrollback — and they're wiped from it 45 seconds later by a detached helper
232
+ (the CLI exits immediately, so an in-process timer would never fire). If
233
+ you've copied something else in the meantime, yours is left alone. One
216
234
  deliberate asymmetry: CLI `find` does *not* search passwords, because a CLI
217
235
  argument lands in your shell history forever — the TUI search box does,
218
236
  because nothing you type there is logged anywhere.
@@ -283,8 +301,10 @@ accounts from 2013. `paladin rm <name>` the corpses, or prune the CSV first.
283
301
 
284
302
  I'd rather you know these than find them:
285
303
 
286
- - No clipboard auto-clear yet: a copied password stays on the clipboard
287
- until you copy over it. Copy something else when you're done.
304
+ - The 45-second clipboard window is still a window: anything watching your
305
+ clipboard in that time sees the password. Set
306
+ `PALADIN_CLIPBOARD_SECONDS` lower if that bothers you (or `0` to keep the
307
+ old forever-behaviour).
288
308
  - While a command runs, decrypted data briefly exists in process memory.
289
309
  Malware already on your machine could read it — true of every password
290
310
  manager ever made; the vault protects the file at rest, not a compromised
@@ -23,23 +23,84 @@ def generate(length: int = 20) -> str:
23
23
  return "".join(secrets.choice(alphabet) for _ in range(length))
24
24
 
25
25
 
26
- def to_clipboard(text: str) -> bool:
27
- # ponytail: no auto-clear timer; clear manually or copy something else over it
28
- # linux needs a tool installed; macOS (pbcopy) and Windows (clip) ship theirs
29
- for cmd in (
30
- ["wl-copy"],
31
- ["xclip", "-selection", "clipboard"],
32
- ["xsel", "-b"],
33
- ["pbcopy"],
34
- ["clip"],
35
- ["clip.exe"], # windows tool as seen from inside WSL
36
- ):
26
+ # seconds a copied password lingers before it's wiped; 0 disables
27
+ CLIPBOARD_TTL = int(os.environ.get("PALADIN_CLIPBOARD_SECONDS", "45"))
28
+
29
+ # linux needs a tool installed; macOS (pbcopy) and Windows (clip) ship theirs
30
+ _COPY_CMDS = (
31
+ ["wl-copy"],
32
+ ["xclip", "-selection", "clipboard"],
33
+ ["xsel", "-b"],
34
+ ["pbcopy"],
35
+ ["clip"],
36
+ ["clip.exe"], # windows tool as seen from inside WSL
37
+ )
38
+ _PASTE_CMDS = (
39
+ ["wl-paste", "-n"],
40
+ ["xclip", "-selection", "clipboard", "-o"],
41
+ ["xsel", "-b"],
42
+ ["pbpaste"],
43
+ ["powershell.exe", "-noprofile", "-command", "Get-Clipboard"],
44
+ )
45
+
46
+
47
+ def to_clipboard(text: str, auto_clear: bool = True) -> bool:
48
+ for cmd in _COPY_CMDS:
37
49
  if shutil.which(cmd[0]):
38
50
  subprocess.run(cmd, input=text.encode(), check=True)
51
+ if auto_clear and text and CLIPBOARD_TTL > 0:
52
+ _clear_clipboard_later(text)
39
53
  return True
40
54
  return False
41
55
 
42
56
 
57
+ def from_clipboard() -> str | None:
58
+ """Current clipboard contents, or None if we can't read it."""
59
+ for cmd in _PASTE_CMDS:
60
+ if shutil.which(cmd[0]):
61
+ try:
62
+ out = subprocess.run(cmd, capture_output=True, check=True).stdout
63
+ except subprocess.CalledProcessError:
64
+ return None
65
+ return out.decode(errors="replace").rstrip("\r\n")
66
+ return None
67
+
68
+
69
+ def _clear_clipboard_later(text: str) -> None:
70
+ """Hand the password to a detached child that wipes it later — the CLI
71
+ exits immediately after copying, so an in-process timer would never fire."""
72
+ try:
73
+ proc = subprocess.Popen(
74
+ [sys.executable, "-c", "from pw_manager.cli import _clear_worker; _clear_worker()"],
75
+ stdin=subprocess.PIPE,
76
+ stdout=subprocess.DEVNULL,
77
+ stderr=subprocess.DEVNULL,
78
+ start_new_session=True,
79
+ )
80
+ proc.stdin.write(text.encode()) # via stdin, never argv (argv shows in `ps`)
81
+ proc.stdin.close()
82
+ except OSError:
83
+ pass # clipboard hygiene is best-effort; never break the copy itself
84
+
85
+
86
+ def _clear_worker() -> None:
87
+ """Child: wait out the TTL, then clear the clipboard only if it still holds
88
+ the password we put there — never clobber something the user copied since."""
89
+ import time
90
+
91
+ copied = sys.stdin.buffer.read().decode()
92
+ time.sleep(CLIPBOARD_TTL)
93
+ current = from_clipboard()
94
+ if current is None or current == copied:
95
+ to_clipboard("", auto_clear=False) # no recursion
96
+
97
+
98
+ def copied_msg() -> str:
99
+ if CLIPBOARD_TTL > 0:
100
+ return f"password copied to clipboard (clears in {CLIPBOARD_TTL}s)"
101
+ return "password copied to clipboard"
102
+
103
+
43
104
  def ask_master(confirm: bool = False) -> str:
44
105
  pw = getpass.getpass("master password: ")
45
106
  if confirm:
@@ -64,9 +125,28 @@ def cmd_init(args):
64
125
 
65
126
 
66
127
  def cmd_mobile(args):
128
+ import time
129
+
67
130
  from . import mobile
68
131
 
69
132
  try:
133
+ if args.stop:
134
+ print("background session stopped" if mobile.stop_daemon() else "nothing running")
135
+ return
136
+ if args.status:
137
+ state = mobile.read_state()
138
+ if not state:
139
+ print("no background session running")
140
+ return
141
+ left = (state["expires"] - time.time()) / 86400
142
+ print(f"serving {state['url']}\nlink expires in {left:.1f} days (pid {state['pid']})")
143
+ return
144
+ if args.background:
145
+ url = mobile.start_daemon(VAULT_PATH, stable_url=args.url)
146
+ mobile.print_qr(url)
147
+ print(" running in the background — it survives closing this terminal.")
148
+ print(" 'paladin mobile --status' to check, '--stop' to end it.\n")
149
+ return
70
150
  mobile.run(VAULT_PATH, stable_url=args.url)
71
151
  except mobile.MobileError as e:
72
152
  sys.exit(str(e))
@@ -103,7 +183,7 @@ def cmd_add(args):
103
183
  vault.save(VAULT_PATH, master, entries)
104
184
  print(f"stored '{args.name}'")
105
185
  if args.gen and to_clipboard(pw):
106
- print("password copied to clipboard")
186
+ print(copied_msg())
107
187
 
108
188
 
109
189
  def cmd_get(args):
@@ -115,7 +195,7 @@ def cmd_get(args):
115
195
  if e.get("notes"):
116
196
  print(f"notes: {e['notes']}")
117
197
  if to_clipboard(e["pw"]):
118
- print("password copied to clipboard")
198
+ print(copied_msg())
119
199
  else:
120
200
  # no clipboard tool found; showing it is the only option left
121
201
  print(f"password: {e['pw']}")
@@ -237,6 +317,12 @@ def main():
237
317
  i.add_argument("csv_file")
238
318
  m = sub.add_parser("mobile", help="open your vault on your phone over an HTTPS tunnel")
239
319
  m.add_argument("--url", help="serve at your own https subdomain instead of a quick tunnel")
320
+ m.add_argument(
321
+ "-b", "--background", action="store_true",
322
+ help="keep serving after this terminal closes (until the link expires)",
323
+ )
324
+ m.add_argument("--status", action="store_true", help="is a background session running?")
325
+ m.add_argument("--stop", action="store_true", help="stop the background session")
240
326
  sub.add_parser("about", help="meet the knight")
241
327
 
242
328
  args = p.parse_args()
@@ -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."""
@@ -251,13 +328,7 @@ def stop_session(server, proc) -> None:
251
328
  pass
252
329
 
253
330
 
254
- def run(vault_path: str, stable_url: str | None = None, port: int = 0) -> None:
255
- def fetched():
256
- print(f" vault page served to a device at {time.strftime('%H:%M:%S')}")
257
-
258
- print("\n creating your secure link…", flush=True)
259
- server, proc, url = start_session(vault_path, stable_url, port, on_fetch=fetched)
260
-
331
+ def print_qr(url: str) -> None:
261
332
  import qrcode
262
333
 
263
334
  qr = qrcode.QRCode(border=1)
@@ -265,13 +336,45 @@ def run(vault_path: str, stable_url: str | None = None, port: int = 0) -> None:
265
336
  print("\n scan with your phone camera:\n")
266
337
  qr.print_ascii(invert=True)
267
338
  print(f"\n {url}\n")
268
- print(" the master password is typed on your phone; only ciphertext leaves this machine.")
269
- 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")
270
367
 
271
368
  try:
272
- while True:
369
+ while time.time() < expires: # the link's 30 days, enforced for real
273
370
  time.sleep(1)
371
+ print("\nlink expired after 30 days — run 'paladin mobile' again for a fresh one.")
274
372
  except KeyboardInterrupt:
275
373
  print("\nsession ended.")
276
374
  finally:
277
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
@@ -21,7 +21,7 @@ from textual.widgets import DataTable, Footer, Input, Label, OptionList, Static
21
21
  from textual.widgets.option_list import Option
22
22
 
23
23
  from . import logo, vault
24
- from .cli import VAULT_PATH, generate, import_csv, to_clipboard
24
+ from .cli import CLIPBOARD_TTL, VAULT_PATH, generate, import_csv, to_clipboard
25
25
 
26
26
  THEME_FILE = os.path.expanduser("~/.local/share/pw-manager/theme")
27
27
 
@@ -422,7 +422,8 @@ class MainScreen(Screen):
422
422
  if name is None:
423
423
  return
424
424
  if to_clipboard(self.app.entries[name]["pw"]):
425
- self.notify(f"password for '{name}' copied", severity="information")
425
+ ttl = f" (clears in {CLIPBOARD_TTL}s)" if CLIPBOARD_TTL > 0 else ""
426
+ self.notify(f"password for '{name}' copied{ttl}", severity="information")
426
427
  else:
427
428
  self.notify(
428
429
  "no clipboard tool found (on Linux: install wl-clipboard)",
@@ -476,7 +477,7 @@ class MainScreen(Screen):
476
477
  self.app.push_screen(ConfirmModal(f"delete '{name}'?"), done)
477
478
 
478
479
  def action_theme(self) -> None:
479
- self.app.push_screen(ThemeModal())
480
+ self.app._theme() # same picker the palette opens
480
481
 
481
482
  def action_search(self) -> None:
482
483
  self.query_one("#search", Input).focus()
@@ -612,6 +613,18 @@ class PwApp(App):
612
613
  with open(THEME_FILE, "w") as f:
613
614
  f.write(theme.name)
614
615
 
616
+ def get_system_commands(self, screen: Screen):
617
+ """Swap Textual's built-in 'Theme' entry for our picker, so the
618
+ palette and the `t` key open the same thing."""
619
+ for command in super().get_system_commands(screen):
620
+ if command.title == "Theme":
621
+ yield SystemCommand("Theme", "Change the current theme", self._theme)
622
+ else:
623
+ yield command
624
+
625
+ def _theme(self) -> None:
626
+ self.push_screen(ThemeModal())
627
+
615
628
  def _import_csv(self) -> None:
616
629
  def done(path: str | None) -> None:
617
630
  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.5"
7
+ version = "0.4.7"
8
8
  description = "The Paladin — a local encrypted password manager that lives in your terminal"
9
9
  readme = "README.md"
10
10
  license = "MIT"