SmartHeal commited on
Commit
51809a1
·
verified ·
1 Parent(s): 7054a3a

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +191 -123
src/streamlit_app.py CHANGED
@@ -1,15 +1,17 @@
1
  import json
2
- import os
3
  import time
4
  import base64
5
  import secrets
 
 
6
  from dataclasses import dataclass, asdict
7
  from typing import List, Optional, Dict, Any
8
 
9
  import streamlit as st
10
  import pandas as pd # for Excel/CSV import
11
 
12
- # ---- Crypto deps (install: pip install cryptography argon2-cffi==23.1.0) ----
 
13
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
14
  from cryptography.hazmat.primitives import hashes
15
  from cryptography.hazmat.primitives.kdf.hkdf import HKDF
@@ -17,9 +19,15 @@ from cryptography.hazmat.primitives.hmac import HMAC
17
  from cryptography.hazmat.backends import default_backend
18
  from argon2.low_level import hash_secret_raw, Type
19
 
20
- APP_TITLE = "SmartPass — Local, Zero‑Knowledge Password Manager"
21
  VERSION = 1
22
 
 
 
 
 
 
 
23
  # -------------------------- Utility helpers --------------------------
24
 
25
  def b64e(b: bytes) -> str:
@@ -52,8 +60,7 @@ class KDFParams:
52
  hash_len: int = 32
53
 
54
  def to_dict(self):
55
- d = asdict(self)
56
- return d
57
 
58
 
59
  @dataclass
@@ -131,10 +138,8 @@ def aesgcm_decrypt(key: bytes, nonce_b64: str, ct_b64: str, aad: Optional[bytes]
131
  # -------------------------- Integrity (HMAC) --------------------------
132
 
133
  def canonical_vault_for_hmac(v: Dict[str, Any]) -> bytes:
134
- # Exclude integrity field itself to avoid recursion
135
  tmp = dict(v)
136
  tmp.pop("integrity_hmac_b64", None)
137
- # Stable ordering
138
  return json.dumps(tmp, sort_keys=True, separators=(",", ":")).encode("utf-8")
139
 
140
 
@@ -162,7 +167,6 @@ def new_vault(password: str) -> Vault:
162
  kdf.salt_b64 = b64e(secrets.token_bytes(16))
163
  master_key = derive_master_key(password, kdf)
164
 
165
- # Derive subkeys
166
  wrap_key = hkdf_expand(master_key, b"wrap-key") # for wrapping data key
167
  mac_key = hkdf_expand(master_key, b"vault-mac") # for HMAC integrity
168
 
@@ -175,23 +179,18 @@ def new_vault(password: str) -> Vault:
175
  items=[],
176
  integrity_hmac_b64="",
177
  )
178
- # Compute initial HMAC
179
- vdict = vault.to_dict()
180
- vdict["integrity_hmac_b64"] = ""
181
- hmac_b64 = compute_hmac(mac_key, vdict)
182
- vault.integrity_hmac_b64 = hmac_b64
183
- # Clear secrets from locals (best-effort)
184
  return vault
185
 
186
 
187
  def unlock_vault(vault_dict: Dict[str, Any], password: str) -> Dict[str, Any]:
188
- # Returns {"data_key": bytes, "mac_key": bytes}
189
- kdf = KDFParams(**vault_dict["kdf"]) if isinstance(vault_dict["kdf"], dict) else vault_dict["kdf"]
190
  master_key = derive_master_key(password, kdf)
191
  wrap_key = hkdf_expand(master_key, b"wrap-key")
192
  mac_key = hkdf_expand(master_key, b"vault-mac")
193
 
194
- # Verify integrity first
195
  if not verify_hmac(mac_key, vault_dict):
196
  raise ValueError("Integrity check failed. The vault may be corrupted or the password is incorrect.")
197
 
@@ -233,6 +232,50 @@ def update_item(data_key: bytes, it: VaultItem, payload: Dict[str, Any]) -> Vaul
233
  return it
234
 
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  # -------------------------- Streamlit UI --------------------------
237
  st.set_page_config(page_title=APP_TITLE, page_icon="🔐", layout="wide")
238
 
@@ -263,47 +306,10 @@ def do_lock():
263
  touch()
264
 
265
 
266
- # Sidebar: Create / Open / Lock / Export
267
- # -------------------------- Import helpers --------------------------
268
-
269
- def _read_tabular(file) -> pd.DataFrame:
270
- name = (file.name or "").lower()
271
- if name.endswith(".csv"):
272
- return pd.read_csv(file)
273
- # Excel requires openpyxl (installed implicitly by pandas if not, pip install openpyxl)
274
- return pd.read_excel(file)
275
-
276
-
277
- def _standardize_columns(df: pd.DataFrame) -> pd.DataFrame:
278
- # Map common column names to our schema
279
- aliases = {
280
- "name": ["name", "title", "site", "account"],
281
- "username": ["username", "user", "login", "email"],
282
- "url": ["url", "link", "website"],
283
- "password": ["password", "pass", "pwd"],
284
- "note": ["note", "notes", "remark", "remarks"],
285
- "tags": ["tags", "label", "labels", "category", "categories"],
286
- "type": ["type", "item_type"],
287
- }
288
- colmap: Dict[str, str] = {}
289
- lower_cols = {c.lower().strip(): c for c in df.columns}
290
- for target, alias_list in aliases.items():
291
- for a in alias_list:
292
- if a in lower_cols:
293
- colmap[target] = lower_cols[a]
294
- break
295
- # Ensure all expected columns exist; create if missing
296
- for col in ["name", "username", "url", "password", "note", "tags", "type"]:
297
- if col not in colmap:
298
- df[col] = ""
299
- colmap[col] = col
300
- return df[[colmap[c] for c in ["name", "username", "url", "password", "note", "tags", "type"]]]\
301
- .rename(columns={colmap[c]: c for c in colmap})
302
-
303
-
304
  with st.sidebar:
305
  st.title("🔐 SmartPass")
306
- st.caption("Local, file-based, zero-knowledge vault. No servers.")
307
 
308
  # Auto-lock
309
  lock_minutes = st.number_input("Auto-lock (minutes)", min_value=1, max_value=120, value=5)
@@ -324,13 +330,17 @@ with st.sidebar:
324
  st.error("Please enter your master password.")
325
  else:
326
  try:
327
- vault_dict = json.loads(up.getvalue().decode("utf-8"))
 
 
 
 
328
  keys = unlock_vault(vault_dict, password_open)
329
  st.session_state.vault = vault_dict
330
  st.session_state.keys = keys
331
  st.session_state.unlocked = True
332
  touch()
333
- st.success("Vault unlocked.")
334
  except Exception as e:
335
  st.session_state.unlocked = False
336
  st.error(f"Failed to unlock: {e}")
@@ -341,7 +351,7 @@ with st.sidebar:
341
  new_pw2 = st.text_input("Confirm password", type="password")
342
  with st.expander("Advanced KDF (Argon2id)"):
343
  m_mib = st.slider("Memory (MiB)", 64, 512, 128, step=32)
344
- t_cost = st.slider("Iterations", 1, 5, 3)
345
  par = st.slider("Parallelism", 1, 4, 1)
346
  if st.button("Create Vault", use_container_width=True):
347
  if not new_pw:
@@ -350,66 +360,74 @@ with st.sidebar:
350
  st.error("Passwords do not match.")
351
  else:
352
  v = new_vault(new_pw)
353
- # override KDF knobs from UI
354
  v.kdf.m_cost_kib = m_mib * 1024
355
  v.kdf.t_cost = t_cost
356
  v.kdf.parallelism = par
357
- # Recompute HMAC with same master pw but updated kdf params
358
  mk = derive_master_key(new_pw, v.kdf)
359
  mac_key = hkdf_expand(mk, b"vault-mac")
360
- vdict = v.to_dict()
361
- vdict["integrity_hmac_b64"] = ""
362
- vdict["integrity_hmac_b64"] = compute_hmac(mac_key, vdict)
363
  st.session_state.vault = vdict
364
  st.session_state.keys = unlock_vault(vdict, new_pw)
365
  st.session_state.unlocked = True
 
 
 
366
  touch()
367
- st.success("New vault created and unlocked.")
368
 
369
  st.divider()
370
- st.subheader("Export")
371
- if st.session_state.vault is not None:
372
- export_json = json.dumps(st.session_state.vault, separators=(",", ":"))
373
- st.download_button(
374
- label="Download vault JSON",
375
- data=export_json,
376
- file_name="vault.smartpass.json",
377
- mime="application/json",
378
- use_container_width=True,
379
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
380
 
381
- st.divider()
382
- st.subheader("Import from Excel/CSV")
383
- imp = st.file_uploader("Upload creds file (.xlsx/.xls/.csv)", type=["xlsx","xls","csv"], accept_multiple_files=False, key="importer")
384
- if imp is not None and st.session_state.unlocked:
385
- try:
386
- df = _read_tabular(imp)
387
- df = _standardize_columns(df)
388
- # Default type to 'login' if empty
389
- df['type'] = df['type'].fillna('').astype(str).str.lower().replace({'': 'login'})
390
- added = 0
391
- items_local: List[VaultItem] = [VaultItem(**it) for it in st.session_state.vault.get("items", [])]
392
- for _, row in df.iterrows():
393
- payload = {
394
- "type": (row.get('type') or 'login') if (row.get('type') in ['login','note']) else 'login',
395
- "name": str(row.get('name') or '').strip(),
396
- "username": str(row.get('username') or '').strip(),
397
- "url": str(row.get('url') or '').strip(),
398
- "password": str(row.get('password') or '').strip(),
399
- "note": str(row.get('note') or '').strip(),
400
- "tags": [t.strip() for t in str(row.get('tags') or '').split(',') if t.strip()],
401
- }
402
- # Skip empty rows
403
- if not any(payload.values()):
404
- continue
405
- items_local.append(encrypt_item(st.session_state.keys["data_key"], payload))
406
- added += 1
407
- st.session_state.vault["items"] = [it.to_dict() for it in items_local]
408
- re_hmac(st.session_state.vault, st.session_state.keys["mac_key"])
409
- st.success(f"Imported {added} item(s).")
410
- st.experimental_rerun()
411
- except Exception as e:
412
- st.error(f"Import failed: {e}")
413
 
414
  # -------------------------- Main Area --------------------------
415
  st.title(APP_TITLE)
@@ -418,7 +436,7 @@ if not st.session_state.unlocked:
418
  st.info("Unlock or create a vault from the left sidebar to begin.")
419
  st.stop()
420
 
421
- # Search and actions
422
  col1, col2, col3, col4 = st.columns([3, 1, 1, 1])
423
  with col1:
424
  q = st.text_input("Search (name / username / url / tags)", value="")
@@ -433,13 +451,59 @@ with col4:
433
  do_lock()
434
  st.experimental_rerun()
435
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  touch()
437
 
438
- # Items table
439
  vault_dict = st.session_state.vault
440
  keys = st.session_state.keys
441
 
442
- # Decrypt all (in-memory only)
443
  items: List[VaultItem] = [VaultItem(**it) for it in vault_dict.get("items", [])]
444
  rows = []
445
  for it in items:
@@ -459,7 +523,7 @@ for it in items:
459
  except Exception:
460
  rows.append({"_id": it.id, "type": it.type, "name": "<decrypt error>", "username": "", "url": "", "password": "", "note": "", "tags": "", "updated": ""})
461
 
462
- # Filter
463
  if q.strip():
464
  Q = q.lower()
465
  rows = [r for r in rows if any(Q in (str(r[k]) or "").lower() for k in ["name", "username", "url", "tags", "note"]) ]
@@ -485,11 +549,13 @@ for r in rows:
485
  items = [it for it in items if it.id != r['_id']]
486
  vault_dict["items"] = [it.to_dict() for it in items]
487
  re_hmac(vault_dict, keys["mac_key"])
 
 
488
  st.session_state.vault = vault_dict
489
- st.success("Deleted.")
490
  st.experimental_rerun()
491
 
492
- # Add / Edit forms
493
  if st.session_state.get("add_type"):
494
  with st.modal("Add Item"):
495
  add_type = st.session_state.get("add_type")
@@ -513,9 +579,10 @@ if st.session_state.get("add_type"):
513
  items.append(new_item)
514
  vault_dict["items"] = [it.to_dict() for it in items]
515
  re_hmac(vault_dict, keys["mac_key"])
 
516
  st.session_state.vault = vault_dict
517
  st.session_state["add_type"] = None
518
- st.success("Item added.")
519
  st.experimental_rerun()
520
  if st.button("Cancel"):
521
  st.session_state["add_type"] = None
@@ -544,25 +611,26 @@ if st.session_state.get("edit_id"):
544
  "tags": [t.strip() for t in tags.split(",") if t.strip()],
545
  }
546
  updated = update_item(keys["data_key"], original, new_payload)
547
- # Replace in list
548
  items = [updated if it.id == eid else it for it in items]
549
  vault_dict["items"] = [it.to_dict() for it in items]
550
  re_hmac(vault_dict, keys["mac_key"])
 
551
  st.session_state.vault = vault_dict
552
  st.session_state["edit_id"] = None
553
- st.success("Updated.")
554
  st.experimental_rerun()
555
  if st.button("Cancel"):
556
  st.session_state["edit_id"] = None
557
 
558
- # Security footnotes
559
  with st.expander("Security Notes & Tips"):
560
  st.markdown(
561
- """
562
- - **All encryption/decryption happens locally.** Your vault is a JSON file that only contains ciphertext. Keep backups.
563
- - **Zero-knowledge:** The app never stores your master password. Only a key derived via **Argon2id** is used transiently in memory.
564
- - **Integrity:** The vault includes an HMAC to detect tampering/corruption. Wrong password also fails integrity.
565
- - **Clipboard caution:** This demo does not auto-copy passwords to avoid OS clipboard risks.
566
- - **KDF tuning:** If your device is slow, reduce memory/iterations in the sidebar's Advanced KDF.
 
567
  """
568
  )
 
1
  import json
 
2
  import time
3
  import base64
4
  import secrets
5
+ import os
6
+ from pathlib import Path
7
  from dataclasses import dataclass, asdict
8
  from typing import List, Optional, Dict, Any
9
 
10
  import streamlit as st
11
  import pandas as pd # for Excel/CSV import
12
 
13
+ # ---- Crypto deps ----
14
+ # pip install: streamlit cryptography argon2-cffi pandas openpyxl
15
  from cryptography.hazmat.primitives.ciphers.aead import AESGCM
16
  from cryptography.hazmat.primitives import hashes
17
  from cryptography.hazmat.primitives.kdf.hkdf import HKDF
 
19
  from cryptography.hazmat.backends import default_backend
20
  from argon2.low_level import hash_secret_raw, Type
21
 
22
+ APP_TITLE = "SmartPass — Local, Zero‑Knowledge Password Manager (uploads-enabled)"
23
  VERSION = 1
24
 
25
+ # -------------------------- Uploads / temp dirs --------------------------
26
+ UPLOAD_DIR = Path(os.environ.get("UPLOAD_DIR", "./uploads")).resolve()
27
+ TMP_DIR = Path(os.environ.get("TMPDIR", "./tmp")).resolve()
28
+ for p in (UPLOAD_DIR, TMP_DIR):
29
+ p.mkdir(parents=True, exist_ok=True)
30
+
31
  # -------------------------- Utility helpers --------------------------
32
 
33
  def b64e(b: bytes) -> str:
 
60
  hash_len: int = 32
61
 
62
  def to_dict(self):
63
+ return asdict(self)
 
64
 
65
 
66
  @dataclass
 
138
  # -------------------------- Integrity (HMAC) --------------------------
139
 
140
  def canonical_vault_for_hmac(v: Dict[str, Any]) -> bytes:
 
141
  tmp = dict(v)
142
  tmp.pop("integrity_hmac_b64", None)
 
143
  return json.dumps(tmp, sort_keys=True, separators=(",", ":")).encode("utf-8")
144
 
145
 
 
167
  kdf.salt_b64 = b64e(secrets.token_bytes(16))
168
  master_key = derive_master_key(password, kdf)
169
 
 
170
  wrap_key = hkdf_expand(master_key, b"wrap-key") # for wrapping data key
171
  mac_key = hkdf_expand(master_key, b"vault-mac") # for HMAC integrity
172
 
 
179
  items=[],
180
  integrity_hmac_b64="",
181
  )
182
+ vdict = vault.to_dict(); vdict["integrity_hmac_b64"] = ""
183
+ vault.integrity_hmac_b64 = compute_hmac(mac_key, vdict)
 
 
 
 
184
  return vault
185
 
186
 
187
  def unlock_vault(vault_dict: Dict[str, Any], password: str) -> Dict[str, Any]:
188
+ kdfd = vault_dict["kdf"]
189
+ kdf = KDFParams(**kdfd) if isinstance(kdfd, dict) else kdfd
190
  master_key = derive_master_key(password, kdf)
191
  wrap_key = hkdf_expand(master_key, b"wrap-key")
192
  mac_key = hkdf_expand(master_key, b"vault-mac")
193
 
 
194
  if not verify_hmac(mac_key, vault_dict):
195
  raise ValueError("Integrity check failed. The vault may be corrupted or the password is incorrect.")
196
 
 
232
  return it
233
 
234
 
235
+ # -------------------------- Import helpers (Excel/CSV) --------------------------
236
+
237
+ def _read_tabular(file_or_path) -> pd.DataFrame:
238
+ # Accept a file-like object or a path
239
+ if hasattr(file_or_path, "read"):
240
+ # Try both excel/csv from buffer name fallback
241
+ try:
242
+ return pd.read_excel(file_or_path)
243
+ except Exception:
244
+ file_or_path.seek(0)
245
+ return pd.read_csv(file_or_path)
246
+ name = str(file_or_path).lower()
247
+ if name.endswith(".csv"):
248
+ return pd.read_csv(file_or_path)
249
+ return pd.read_excel(file_or_path)
250
+
251
+
252
+ def _standardize_columns(df: pd.DataFrame) -> pd.DataFrame:
253
+ aliases = {
254
+ "name": ["name", "title", "site", "account"],
255
+ "username": ["username", "user", "login", "email"],
256
+ "url": ["url", "link", "website", "domain"],
257
+ "password": ["password", "pass", "pwd"],
258
+ "note": ["note", "notes", "remark", "remarks"],
259
+ "tags": ["tags", "label", "labels", "category", "categories"],
260
+ "type": ["type", "item_type"],
261
+ }
262
+ colmap: Dict[str, str] = {}
263
+ lower_cols = {c.lower().strip(): c for c in df.columns}
264
+ for target, alias_list in aliases.items():
265
+ for a in alias_list:
266
+ if a in lower_cols:
267
+ colmap[target] = lower_cols[a]
268
+ break
269
+ for col in ["name", "username", "url", "password", "note", "tags", "type"]:
270
+ if col not in colmap:
271
+ df[col] = ""
272
+ colmap[col] = col
273
+ return (
274
+ df[[colmap[c] for c in ["name", "username", "url", "password", "note", "tags", "type"]]]
275
+ .rename(columns={colmap[c]: c for c in colmap})
276
+ )
277
+
278
+
279
  # -------------------------- Streamlit UI --------------------------
280
  st.set_page_config(page_title=APP_TITLE, page_icon="🔐", layout="wide")
281
 
 
306
  touch()
307
 
308
 
309
+ # Sidebar: Create / Open / Lock / Export / Import
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  with st.sidebar:
311
  st.title("🔐 SmartPass")
312
+ st.caption(f"Uploads dir: {UPLOAD_DIR}")
313
 
314
  # Auto-lock
315
  lock_minutes = st.number_input("Auto-lock (minutes)", min_value=1, max_value=120, value=5)
 
330
  st.error("Please enter your master password.")
331
  else:
332
  try:
333
+ # Persist a copy of uploaded vault and load from disk
334
+ saved_vault_path = UPLOAD_DIR / (up.name or "uploaded_vault.json")
335
+ with open(saved_vault_path, "wb") as f:
336
+ f.write(up.getbuffer())
337
+ vault_dict = json.loads(saved_vault_path.read_text(encoding="utf-8"))
338
  keys = unlock_vault(vault_dict, password_open)
339
  st.session_state.vault = vault_dict
340
  st.session_state.keys = keys
341
  st.session_state.unlocked = True
342
  touch()
343
+ st.success(f"Vault unlocked. Saved copy: {saved_vault_path}")
344
  except Exception as e:
345
  st.session_state.unlocked = False
346
  st.error(f"Failed to unlock: {e}")
 
351
  new_pw2 = st.text_input("Confirm password", type="password")
352
  with st.expander("Advanced KDF (Argon2id)"):
353
  m_mib = st.slider("Memory (MiB)", 64, 512, 128, step=32)
354
+ t_cost = st.slider("Iterations", 1, 6, 3)
355
  par = st.slider("Parallelism", 1, 4, 1)
356
  if st.button("Create Vault", use_container_width=True):
357
  if not new_pw:
 
360
  st.error("Passwords do not match.")
361
  else:
362
  v = new_vault(new_pw)
 
363
  v.kdf.m_cost_kib = m_mib * 1024
364
  v.kdf.t_cost = t_cost
365
  v.kdf.parallelism = par
 
366
  mk = derive_master_key(new_pw, v.kdf)
367
  mac_key = hkdf_expand(mk, b"vault-mac")
368
+ vdict = v.to_dict(); vdict["integrity_hmac_b64"] = compute_hmac(mac_key, vdict)
 
 
369
  st.session_state.vault = vdict
370
  st.session_state.keys = unlock_vault(vdict, new_pw)
371
  st.session_state.unlocked = True
372
+ # Immediately save a new vault file under uploads
373
+ new_path = UPLOAD_DIR / "vault.smartpass.json"
374
+ new_path.write_text(json.dumps(vdict, separators=(",", ":")), encoding="utf-8")
375
  touch()
376
+ st.success(f"New vault created, unlocked, and saved at {new_path}.")
377
 
378
  st.divider()
379
+ st.subheader("Export / Backup")
380
+ if st.session_state.vault is not None:
381
+ export_json = json.dumps(st.session_state.vault, separators=(",", ":"))
382
+ st.download_button(
383
+ label="Download vault JSON",
384
+ data=export_json,
385
+ file_name="vault.smartpass.json",
386
+ mime="application/json",
387
+ use_container_width=True,
388
+ )
389
+ if st.button("Save vault to uploads/", use_container_width=True):
390
+ path = UPLOAD_DIR / "vault.smartpass.json"
391
+ path.write_text(export_json, encoding="utf-8")
392
+ st.success(f"Saved: {path}")
393
+
394
+ st.divider()
395
+ st.subheader("Import from Excel/CSV")
396
+ st.caption("Headers: name, username, url, password, note, tags, optional type (login/note). Aliases auto-mapped.")
397
+ imp = st.file_uploader("Upload creds file (.xlsx/.xls/.csv)", type=["xlsx","xls","csv"], accept_multiple_files=False, key="importer")
398
+ if imp is not None and st.session_state.unlocked:
399
+ try:
400
+ saved_creds_path = UPLOAD_DIR / (imp.name or "creds_upload.xlsx")
401
+ with open(saved_creds_path, "wb") as f:
402
+ f.write(imp.getbuffer())
403
+ df = _read_tabular(saved_creds_path)
404
+ df = _standardize_columns(df)
405
+ df['type'] = df['type'].fillna('').astype(str).str.lower().replace({'': 'login'})
406
+ added = 0
407
+ items_local: List[VaultItem] = [VaultItem(**it) for it in st.session_state.vault.get("items", [])]
408
+ for _, row in df.iterrows():
409
+ payload = {
410
+ "type": (row.get('type') or 'login') if (row.get('type') in ['login','note']) else 'login',
411
+ "name": str(row.get('name') or '').strip(),
412
+ "username": str(row.get('username') or '').strip(),
413
+ "url": str(row.get('url') or '').strip(),
414
+ "password": str(row.get('password') or '').strip(),
415
+ "note": str(row.get('note') or '').strip(),
416
+ "tags": [t.strip() for t in str(row.get('tags') or '').split(',') if t.strip()],
417
+ }
418
+ if not any([payload['name'], payload['username'], payload['url'], payload['password'], payload['note'], payload['tags']]):
419
+ continue
420
+ items_local.append(encrypt_item(st.session_state.keys["data_key"], payload))
421
+ added += 1
422
+ st.session_state.vault["items"] = [it.to_dict() for it in items_local]
423
+ re_hmac(st.session_state.vault, st.session_state.keys["mac_key"])
424
+ # Save updated vault immediately
425
+ (UPLOAD_DIR / "vault.smartpass.json").write_text(json.dumps(st.session_state.vault, separators=(",", ":")), encoding="utf-8")
426
+ st.success(f"Imported {added} item(s). Saved updated vault to uploads/.")
427
+ st.experimental_rerun()
428
+ except Exception as e:
429
+ st.error(f"Import failed: {e}")
430
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
 
432
  # -------------------------- Main Area --------------------------
433
  st.title(APP_TITLE)
 
436
  st.info("Unlock or create a vault from the left sidebar to begin.")
437
  st.stop()
438
 
439
+ # Top actions
440
  col1, col2, col3, col4 = st.columns([3, 1, 1, 1])
441
  with col1:
442
  q = st.text_input("Search (name / username / url / tags)", value="")
 
451
  do_lock()
452
  st.experimental_rerun()
453
 
454
+ # Quick Password Lookup
455
+ st.markdown("### 🔎 Quick Password Lookup")
456
+ lookup = st.text_input("Which site/app? (e.g., gmail, netflix.com, bank)", key="quick_lookup")
457
+ if lookup.strip():
458
+ LQ = lookup.strip().lower()
459
+ _matches = []
460
+ _items_all: List[VaultItem] = [VaultItem(**it) for it in st.session_state.vault.get("items", [])]
461
+ for _it in _items_all:
462
+ try:
463
+ _pl = decrypt_item(st.session_state.keys["data_key"], _it)
464
+ except Exception:
465
+ continue
466
+ _name = str(_pl.get("name", ""))
467
+ _user = str(_pl.get("username", ""))
468
+ _url = str(_pl.get("url", ""))
469
+ _tags = ",".join(_pl.get("tags", []))
470
+ hay = " ".join([_name, _user, _url, _tags]).lower()
471
+ if LQ in hay:
472
+ _matches.append((_it, _pl))
473
+ if not _matches:
474
+ st.info("No matches found in your vault. If this account isn't stored here yet, import or add it first.")
475
+ else:
476
+ for _it, _pl in _matches[:10]:
477
+ with st.expander(f"{_pl.get('name') or '(unnamed)'} — {_pl.get('username','')} [{_it.type}]"):
478
+ c1, c2 = st.columns([2, 1])
479
+ with c1:
480
+ st.write(f"**URL:** {_pl.get('url','')}")
481
+ show_pw = st.checkbox("Show password", key=f"show_{_it.id}")
482
+ pw_val = _pl.get('password','') if _it.type == 'login' else ''
483
+ st.text_input("Password", value=pw_val,
484
+ type=("default" if show_pw else "password"),
485
+ key=f"quick_pw_{_it.id}")
486
+ with c2:
487
+ if st.button("Edit", key=f"quick_edit_{_it.id}"):
488
+ st.session_state["edit_id"] = _it.id
489
+ st.session_state["edit_payload"] = {
490
+ **_pl,
491
+ "type": _it.type,
492
+ "name": _pl.get("name",""),
493
+ "username": _pl.get("username",""),
494
+ "url": _pl.get("url",""),
495
+ "password": _pl.get("password",""),
496
+ "note": _pl.get("note",""),
497
+ "tags": ", ".join(_pl.get("tags", [])),
498
+ }
499
+ st.experimental_rerun()
500
+
501
  touch()
502
 
503
+ # Decrypt all for listing
504
  vault_dict = st.session_state.vault
505
  keys = st.session_state.keys
506
 
 
507
  items: List[VaultItem] = [VaultItem(**it) for it in vault_dict.get("items", [])]
508
  rows = []
509
  for it in items:
 
523
  except Exception:
524
  rows.append({"_id": it.id, "type": it.type, "name": "<decrypt error>", "username": "", "url": "", "password": "", "note": "", "tags": "", "updated": ""})
525
 
526
+ # Filter main list
527
  if q.strip():
528
  Q = q.lower()
529
  rows = [r for r in rows if any(Q in (str(r[k]) or "").lower() for k in ["name", "username", "url", "tags", "note"]) ]
 
549
  items = [it for it in items if it.id != r['_id']]
550
  vault_dict["items"] = [it.to_dict() for it in items]
551
  re_hmac(vault_dict, keys["mac_key"])
552
+ # Save updated vault immediately
553
+ (UPLOAD_DIR / "vault.smartpass.json").write_text(json.dumps(vault_dict, separators=(",", ":")), encoding="utf-8")
554
  st.session_state.vault = vault_dict
555
+ st.success("Deleted and saved.")
556
  st.experimental_rerun()
557
 
558
+ # Add / Edit modals
559
  if st.session_state.get("add_type"):
560
  with st.modal("Add Item"):
561
  add_type = st.session_state.get("add_type")
 
579
  items.append(new_item)
580
  vault_dict["items"] = [it.to_dict() for it in items]
581
  re_hmac(vault_dict, keys["mac_key"])
582
+ (UPLOAD_DIR / "vault.smartpass.json").write_text(json.dumps(vault_dict, separators=(",", ":")), encoding="utf-8")
583
  st.session_state.vault = vault_dict
584
  st.session_state["add_type"] = None
585
+ st.success("Item added and saved.")
586
  st.experimental_rerun()
587
  if st.button("Cancel"):
588
  st.session_state["add_type"] = None
 
611
  "tags": [t.strip() for t in tags.split(",") if t.strip()],
612
  }
613
  updated = update_item(keys["data_key"], original, new_payload)
 
614
  items = [updated if it.id == eid else it for it in items]
615
  vault_dict["items"] = [it.to_dict() for it in items]
616
  re_hmac(vault_dict, keys["mac_key"])
617
+ (UPLOAD_DIR / "vault.smartpass.json").write_text(json.dumps(vault_dict, separators=(",", ":")), encoding="utf-8")
618
  st.session_state.vault = vault_dict
619
  st.session_state["edit_id"] = None
620
+ st.success("Updated and saved.")
621
  st.experimental_rerun()
622
  if st.button("Cancel"):
623
  st.session_state["edit_id"] = None
624
 
625
+ # Security notes
626
  with st.expander("Security Notes & Tips"):
627
  st.markdown(
628
+ f"""
629
+ - **All crypto is local.** The vault JSON contains only ciphertext + metadata.
630
+ - **Uploads dir:** `{UPLOAD_DIR}` (configurable via `UPLOAD_DIR`). Vault and imported files are saved here.
631
+ - **Zero-knowledge:** The app never stores your master password; derivation happens in memory.
632
+ - **Integrity:** Vault includes an HMAC to detect tampering/corruption.
633
+ - **KDF tuning:** If your device is slow, reduce Argon2 memory/iterations in the sidebar.
634
+ - **Backups:** Keep copies of your exported vault JSON in safe places.
635
  """
636
  )