SmartHeal commited on
Commit
8475ebb
·
verified ·
1 Parent(s): 5e3b27c

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +86 -656
src/streamlit_app.py CHANGED
@@ -1,669 +1,99 @@
1
- #!/usr/bin/env python3
2
- """
3
- SmartPass — Streamlit Password Manager (Hugging Face Spaces–ready, uploads-enabled)
4
-
5
- • Zero-knowledge: Argon2id KDF → HKDF subkeys → AES-GCM per-item + HMAC integrity
6
- • Excel/CSV import (auto-maps common headers)
7
- • Quick Password Lookup (type a site/app and reveal password)
8
- • Writes to /data by default on Spaces; configurable via UPLOAD_DIR/TMPDIR env vars
9
-
10
- Run locally:
11
- pip install streamlit cryptography argon2-cffi pandas openpyxl
12
- streamlit run streamlit_app.py
13
-
14
- On Hugging Face Spaces (recommended flags):
15
- STREAMLIT_SERVER_ENABLE_XSRF_PROTECTION=false
16
- STREAMLIT_SERVER_ENABLE_CORS=false
17
-
18
- """
19
- from __future__ import annotations
20
- import json
21
- import time
22
- import base64
23
- import secrets
24
- import os
25
- from pathlib import Path
26
- from dataclasses import dataclass, asdict
27
- from typing import List, Optional, Dict, Any
28
-
29
  import streamlit as st
30
- import pandas as pd # Excel/CSV import
31
-
32
- # ---- Crypto deps ----
33
- from cryptography.hazmat.primitives.ciphers.aead import AESGCM
34
- from cryptography.hazmat.primitives import hashes
35
- from cryptography.hazmat.primitives.kdf.hkdf import HKDF
36
- from cryptography.hazmat.primitives.hmac import HMAC
37
- from cryptography.hazmat.backends import default_backend
38
- from argon2.low_level import hash_secret_raw, Type
39
-
40
- APP_TITLE = "SmartPass — Local, Zero‑Knowledge Password Manager"
41
- VERSION = 1
42
-
43
- # -------------------------- Uploads / temp dirs --------------------------
44
- # Detect Hugging Face Spaces and default to /data (writable). Allow env overrides.
45
- IS_HF = any(os.environ.get(k) for k in ("HUGGINGFACE_SPACE", "SPACE_ID", "HF_SPACE_ID"))
46
- DEFAULT_UPLOAD = "/data/uploads" if IS_HF else "./uploads"
47
- DEFAULT_TMP = "/data/tmp" if IS_HF else "./tmp"
48
- UPLOAD_DIR = Path(os.environ.get("UPLOAD_DIR", DEFAULT_UPLOAD)).resolve()
49
- TMP_DIR = Path(os.environ.get("TMPDIR", DEFAULT_TMP)).resolve()
50
- for p in (UPLOAD_DIR, TMP_DIR):
51
- p.mkdir(parents=True, exist_ok=True)
52
-
53
- # -------------------------- Utility helpers --------------------------
54
-
55
- def safe_write_text(path: Path, text: str) -> Path:
56
- """Write text to path, creating parents. If it fails (e.g., perms),
57
- retry under TMP_DIR. Returns the final Path written."""
58
- try:
59
- path.parent.mkdir(parents=True, exist_ok=True)
60
- path.write_text(text, encoding="utf-8")
61
- return path
62
- except Exception:
63
- alt = TMP_DIR / path.name
64
- alt.parent.mkdir(parents=True, exist_ok=True)
65
- alt.write_text(text, encoding="utf-8")
66
- return alt
67
-
68
-
69
- def b64e(b: bytes) -> str:
70
- return base64.b64encode(b).decode("utf-8")
71
-
72
-
73
- def b64d(s: str) -> bytes:
74
- return base64.b64decode(s.encode("utf-8"))
75
-
76
-
77
- def hkdf_expand(key_material: bytes, info: bytes, length: int = 32, salt: Optional[bytes] = None) -> bytes:
78
- hkdf = HKDF(
79
- algorithm=hashes.SHA256(),
80
- length=length,
81
- salt=salt,
82
- info=info,
83
- backend=default_backend(),
84
- )
85
- return hkdf.derive(key_material)
86
-
87
-
88
- # -------------------------- KDF & Keys --------------------------
89
- @dataclass
90
- class KDFParams:
91
- algo: str = "argon2id"
92
- salt_b64: str = ""
93
- m_cost_kib: int = 131072 # 128 MiB
94
- t_cost: int = 3
95
- parallelism: int = 1
96
- hash_len: int = 32
97
-
98
- def to_dict(self):
99
- return asdict(self)
100
-
101
-
102
- @dataclass
103
- class WrappedDataKey:
104
- nonce_b64: str
105
- ct_b64: str
106
-
107
- def to_dict(self):
108
- return asdict(self)
109
-
110
-
111
- @dataclass
112
- class VaultItem:
113
- id: str
114
- type: str # "login" | "note"
115
- enc_blob_b64: str
116
- nonce_b64: str
117
- created_at: float
118
- updated_at: float
119
-
120
- def to_dict(self):
121
- return asdict(self)
122
-
123
-
124
- @dataclass
125
- class Vault:
126
- version: int
127
- kdf: KDFParams
128
- data_key_wrapped: WrappedDataKey
129
- items: List[VaultItem]
130
- integrity_hmac_b64: str
131
-
132
- def to_dict(self):
133
- return {
134
- "version": self.version,
135
- "kdf": self.kdf.to_dict(),
136
- "data_key_wrapped": self.data_key_wrapped.to_dict(),
137
- "items": [it.to_dict() for it in self.items],
138
- "integrity_hmac_b64": self.integrity_hmac_b64,
139
- }
140
-
141
-
142
- # Argon2id derivation → 32B master key bytes
143
-
144
- def derive_master_key(password: str, kdf: KDFParams) -> bytes:
145
- if kdf.algo != "argon2id":
146
- raise ValueError("Unsupported KDF")
147
- salt = b64d(kdf.salt_b64)
148
- mk = hash_secret_raw(
149
- secret=password.encode("utf-8"),
150
- salt=salt,
151
- time_cost=kdf.t_cost,
152
- memory_cost=kdf.m_cost_kib,
153
- parallelism=kdf.parallelism,
154
- hash_len=kdf.hash_len,
155
- type=Type.ID,
156
- )
157
- return mk
158
-
159
-
160
- # -------------------------- Encryption helpers --------------------------
161
-
162
- def aesgcm_encrypt(key: bytes, plaintext: bytes, aad: Optional[bytes] = None) -> Dict[str, str]:
163
- aes = AESGCM(key)
164
- nonce = secrets.token_bytes(12)
165
- ct = aes.encrypt(nonce, plaintext, aad)
166
- return {"nonce_b64": b64e(nonce), "ct_b64": b64e(ct)}
167
-
168
-
169
- def aesgcm_decrypt(key: bytes, nonce_b64: str, ct_b64: str, aad: Optional[bytes] = None) -> bytes:
170
- aes = AESGCM(key)
171
- return aes.decrypt(b64d(nonce_b64), b64d(ct_b64), aad)
172
-
173
-
174
- # -------------------------- Integrity (HMAC) --------------------------
175
-
176
- def canonical_vault_for_hmac(v: Dict[str, Any]) -> bytes:
177
- tmp = dict(v)
178
- tmp.pop("integrity_hmac_b64", None)
179
- return json.dumps(tmp, sort_keys=True, separators=(",", ":")).encode("utf-8")
180
-
181
-
182
- def compute_hmac(mac_key: bytes, v_dict: Dict[str, Any]) -> str:
183
- h = HMAC(mac_key, hashes.SHA256(), backend=default_backend())
184
- h.update(canonical_vault_for_hmac(v_dict))
185
- return b64e(h.finalize())
186
-
187
-
188
- def verify_hmac(mac_key: bytes, v_dict: Dict[str, Any]) -> bool:
189
- try:
190
- expected = v_dict.get("integrity_hmac_b64", "")
191
- h = HMAC(mac_key, hashes.SHA256(), backend=default_backend())
192
- h.update(canonical_vault_for_hmac(v_dict))
193
- h.verify(b64d(expected))
194
- return True
195
- except Exception:
196
- return False
197
-
198
-
199
- # -------------------------- Vault Ops --------------------------
200
-
201
- def new_vault(password: str) -> Vault:
202
- kdf = KDFParams()
203
- kdf.salt_b64 = b64e(secrets.token_bytes(16))
204
- master_key = derive_master_key(password, kdf)
205
-
206
- wrap_key = hkdf_expand(master_key, b"wrap-key") # for wrapping data key
207
- mac_key = hkdf_expand(master_key, b"vault-mac") # for HMAC integrity
208
-
209
- data_key = secrets.token_bytes(32)
210
- wrapped = aesgcm_encrypt(wrap_key, data_key, aad=b"SmartPass:data-key")
211
- vault = Vault(
212
- version=VERSION,
213
- kdf=kdf,
214
- data_key_wrapped=WrappedDataKey(**wrapped),
215
- items=[],
216
- integrity_hmac_b64="",
217
- )
218
- vdict = vault.to_dict(); vdict["integrity_hmac_b64"] = ""
219
- vault.integrity_hmac_b64 = compute_hmac(mac_key, vdict)
220
- return vault
221
-
222
-
223
- def unlock_vault(vault_dict: Dict[str, Any], password: str) -> Dict[str, Any]:
224
- kdfd = vault_dict["kdf"]
225
- kdf = KDFParams(**kdfd) if isinstance(kdfd, dict) else kdfd
226
- master_key = derive_master_key(password, kdf)
227
- wrap_key = hkdf_expand(master_key, b"wrap-key")
228
- mac_key = hkdf_expand(master_key, b"vault-mac")
229
-
230
- if not verify_hmac(mac_key, vault_dict):
231
- raise ValueError("Integrity check failed. The vault may be corrupted or the password is incorrect.")
232
-
233
- w = vault_dict["data_key_wrapped"]
234
- data_key = aesgcm_decrypt(wrap_key, w["nonce_b64"], w["ct_b64"], aad=b"SmartPass:data-key")
235
- return {"data_key": data_key, "mac_key": mac_key}
236
-
237
-
238
- def re_hmac(vault_dict: Dict[str, Any], mac_key: bytes) -> None:
239
- vault_dict["integrity_hmac_b64"] = ""
240
- vault_dict["integrity_hmac_b64"] = compute_hmac(mac_key, vault_dict)
241
-
242
-
243
- # -------------------------- Item Ops --------------------------
244
-
245
- def encrypt_item(data_key: bytes, payload: Dict[str, Any]) -> VaultItem:
246
- now = time.time()
247
- enc = aesgcm_encrypt(data_key, json.dumps(payload).encode("utf-8"))
248
- return VaultItem(
249
- id=secrets.token_hex(8),
250
- type=payload.get("type", "login"),
251
- enc_blob_b64=enc["ct_b64"],
252
- nonce_b64=enc["nonce_b64"],
253
- created_at=now,
254
- updated_at=now,
255
- )
256
-
257
-
258
- def decrypt_item(data_key: bytes, it: VaultItem) -> Dict[str, Any]:
259
- pt = aesgcm_decrypt(data_key, it.nonce_b64, it.enc_blob_b64)
260
- return json.loads(pt.decode("utf-8"))
261
-
262
-
263
- def update_item(data_key: bytes, it: VaultItem, payload: Dict[str, Any]) -> VaultItem:
264
- enc = aesgcm_encrypt(data_key, json.dumps(payload).encode("utf-8"))
265
- it.enc_blob_b64 = enc["ct_b64"]
266
- it.nonce_b64 = enc["nonce_b64"]
267
- it.updated_at = time.time()
268
- return it
269
-
270
-
271
- # -------------------------- Import helpers (Excel/CSV) --------------------------
272
-
273
- def _read_tabular(file_or_path) -> pd.DataFrame:
274
- # Accept file-like or path
275
- if hasattr(file_or_path, "read"):
276
- try:
277
- return pd.read_excel(file_or_path)
278
- except Exception:
279
- try:
280
- file_or_path.seek(0)
281
- except Exception:
282
- pass
283
- return pd.read_csv(file_or_path)
284
- name = str(file_or_path).lower()
285
- if name.endswith(".csv"):
286
- return pd.read_csv(file_or_path)
287
- return pd.read_excel(file_or_path)
288
-
289
-
290
- def _standardize_columns(df: pd.DataFrame) -> pd.DataFrame:
291
- aliases = {
292
- "name": ["name", "title", "site", "account"],
293
- "username": ["username", "user", "login", "email"],
294
- "url": ["url", "link", "website", "domain"],
295
- "password": ["password", "pass", "pwd"],
296
- "note": ["note", "notes", "remark", "remarks"],
297
- "tags": ["tags", "label", "labels", "category", "categories"],
298
- "type": ["type", "item_type"],
299
- }
300
- colmap: Dict[str, str] = {}
301
- lower_cols = {c.lower().strip(): c for c in df.columns}
302
- for target, alias_list in aliases.items():
303
- for a in alias_list:
304
- if a in lower_cols:
305
- colmap[target] = lower_cols[a]
306
  break
307
- for col in ["name", "username", "url", "password", "note", "tags", "type"]:
 
308
  if col not in colmap:
309
  df[col] = ""
310
  colmap[col] = col
311
- return (
312
- df[[colmap[c] for c in ["name", "username", "url", "password", "note", "tags", "type"]]]
313
- .rename(columns={colmap[c]: c for c in colmap})
314
- )
315
-
316
-
317
- # -------------------------- Streamlit UI --------------------------
318
- st.set_page_config(page_title=APP_TITLE, page_icon="🔐", layout="wide")
319
-
320
- if "vault" not in st.session_state:
321
- st.session_state.vault: Optional[Dict[str, Any]] = None
322
- if "unlocked" not in st.session_state:
323
- st.session_state.unlocked = False
324
- if "keys" not in st.session_state:
325
- st.session_state.keys = None # {data_key, mac_key}
326
- if "last_active" not in st.session_state:
327
- st.session_state.last_active = time.time()
328
-
329
-
330
- def touch():
331
- st.session_state.last_active = time.time()
332
-
333
-
334
- def auto_lock(minutes: int):
335
- if st.session_state.unlocked:
336
- if time.time() - st.session_state.last_active > minutes * 60:
337
- st.warning("Auto-locked due to inactivity.")
338
- do_lock()
339
 
340
 
341
- def do_lock():
342
- st.session_state.unlocked = False
343
- st.session_state.keys = None
344
- touch()
345
-
346
-
347
- # Sidebar: Create / Open / Lock / Export / Import
348
- with st.sidebar:
349
- st.title("🔐 SmartPass")
350
- st.caption(f"Uploads dir: {UPLOAD_DIR}")
351
-
352
- # Auto-lock
353
- lock_minutes = st.number_input("Auto-lock (minutes)", min_value=1, max_value=120, value=5)
354
-
355
- if st.session_state.unlocked:
356
- if st.button("Lock Now", use_container_width=True):
357
- do_lock()
358
- auto_lock(lock_minutes)
359
-
360
- st.divider()
361
- st.subheader("Open Vault")
362
- up = st.file_uploader("Upload existing vault (.json)", type=["json"], accept_multiple_files=False)
363
- password_open = st.text_input("Master password", type="password")
364
- if st.button("Unlock", use_container_width=True):
365
- if up is None:
366
- st.error("Please upload a vault file.")
367
- elif not password_open:
368
- st.error("Please enter your master password.")
369
- else:
370
- try:
371
- saved_vault_path = UPLOAD_DIR / (up.name or "uploaded_vault.json")
372
- with open(saved_vault_path, "wb") as f:
373
- f.write(up.getbuffer())
374
- vault_dict = json.loads(saved_vault_path.read_text(encoding="utf-8"))
375
- keys = unlock_vault(vault_dict, password_open)
376
- st.session_state.vault = vault_dict
377
- st.session_state.keys = keys
378
- st.session_state.unlocked = True
379
- touch()
380
- st.success(f"Vault unlocked. Saved copy: {saved_vault_path}")
381
- except Exception as e:
382
- st.session_state.unlocked = False
383
- st.error(f"Failed to unlock: {e}")
384
-
385
- st.divider()
386
- st.subheader("Create New Vault")
387
- new_pw = st.text_input("New master password", type="password")
388
- new_pw2 = st.text_input("Confirm password", type="password")
389
- with st.expander("Advanced KDF (Argon2id)"):
390
- m_mib = st.slider("Memory (MiB)", 64, 512, 128, step=32)
391
- t_cost = st.slider("Iterations", 1, 6, 3)
392
- par = st.slider("Parallelism", 1, 4, 1)
393
- if st.button("Create Vault", use_container_width=True):
394
- if not new_pw:
395
- st.error("Master password required.")
396
- elif new_pw != new_pw2:
397
- st.error("Passwords do not match.")
398
- else:
399
- v = new_vault(new_pw)
400
- v.kdf.m_cost_kib = m_mib * 1024
401
- v.kdf.t_cost = t_cost
402
- v.kdf.parallelism = par
403
- mk = derive_master_key(new_pw, v.kdf)
404
- mac_key = hkdf_expand(mk, b"vault-mac")
405
- vdict = v.to_dict(); vdict["integrity_hmac_b64"] = compute_hmac(mac_key, vdict)
406
- st.session_state.vault = vdict
407
- st.session_state.keys = unlock_vault(vdict, new_pw)
408
- st.session_state.unlocked = True
409
- # Persist the new vault to uploads (fallback to TMP if needed)
410
- new_path = UPLOAD_DIR / "vault.smartpass.json"
411
- _written = safe_write_text(new_path, json.dumps(vdict, separators=(",", ":")))
412
- touch()
413
- st.success(f"New vault created, unlocked, and saved at {_written}.")
414
-
415
- st.divider()
416
- st.subheader("Export / Backup")
417
- if st.session_state.vault is not None:
418
- export_json = json.dumps(st.session_state.vault, separators=(",", ":"))
419
- st.download_button(
420
- label="Download vault JSON",
421
- data=export_json,
422
- file_name="vault.smartpass.json",
423
- mime="application/json",
424
- use_container_width=True,
425
- )
426
- if st.button("Save vault to uploads/", use_container_width=True):
427
- path = UPLOAD_DIR / "vault.smartpass.json"
428
- final_path = safe_write_text(path, export_json)
429
- st.success(f"Saved: {final_path}")
430
-
431
- st.divider()
432
- st.subheader("Import from Excel/CSV")
433
- st.caption("Headers: name, username, url, password, note, tags, optional type (login/note). Aliases auto-mapped.")
434
- imp = st.file_uploader("Upload creds file (.xlsx/.xls/.csv)", type=["xlsx","xls","csv"], accept_multiple_files=False, key="importer")
435
- if imp is not None and st.session_state.unlocked:
436
- try:
437
- saved_creds_path = UPLOAD_DIR / (imp.name or "creds_upload.xlsx")
438
- with open(saved_creds_path, "wb") as f:
439
- f.write(imp.getbuffer())
440
- df = _read_tabular(saved_creds_path)
441
- df = _standardize_columns(df)
442
- df['type'] = df['type'].fillna('').astype(str).str.lower().replace({'': 'login'})
443
- added = 0
444
- items_local: List[VaultItem] = [VaultItem(**it) for it in st.session_state.vault.get("items", [])]
445
- for _, row in df.iterrows():
446
- payload = {
447
- "type": (row.get('type') or 'login') if (row.get('type') in ['login','note']) else 'login',
448
- "name": str(row.get('name') or '').strip(),
449
- "username": str(row.get('username') or '').strip(),
450
- "url": str(row.get('url') or '').strip(),
451
- "password": str(row.get('password') or '').strip(),
452
- "note": str(row.get('note') or '').strip(),
453
- "tags": [t.strip() for t in str(row.get('tags') or '').split(',') if t.strip()],
454
- }
455
- if not any([payload['name'], payload['username'], payload['url'], payload['password'], payload['note'], payload['tags']]):
456
- continue
457
- items_local.append(encrypt_item(st.session_state.keys["data_key"], payload))
458
- added += 1
459
- st.session_state.vault["items"] = [it.to_dict() for it in items_local]
460
- re_hmac(st.session_state.vault, st.session_state.keys["mac_key"])
461
- safe_write_text(UPLOAD_DIR / "vault.smartpass.json", json.dumps(st.session_state.vault, separators=(",", ":")))
462
- st.success(f"Imported {added} item(s). Updated vault saved.")
463
- st.experimental_rerun()
464
- except Exception as e:
465
- st.error(f"Import failed: {e}")
466
-
467
-
468
- # -------------------------- Main Area --------------------------
469
- st.title(APP_TITLE)
470
-
471
- if not st.session_state.unlocked:
472
- st.info("Unlock or create a vault from the left sidebar to begin.")
473
- st.stop()
474
-
475
- # Top actions
476
- col1, col2, col3, col4 = st.columns([3, 1, 1, 1])
477
- with col1:
478
- q = st.text_input("Search (name / username / url / tags)", value="")
479
- with col2:
480
- if st.button("Add Login"):
481
- st.session_state["add_type"] = "login"
482
- with col3:
483
- if st.button("Add Secure Note"):
484
- st.session_state["add_type"] = "note"
485
- with col4:
486
- if st.button("Lock"):
487
- do_lock()
488
- st.experimental_rerun()
489
-
490
- # Quick Password Lookup
491
- st.markdown("### 🔎 Quick Password Lookup")
492
- lookup = st.text_input("Which site/app? (e.g., gmail, netflix.com, bank)", key="quick_lookup")
493
- if lookup.strip():
494
- LQ = lookup.strip().lower()
495
- _matches = []
496
- _items_all: List[VaultItem] = [VaultItem(**it) for it in st.session_state.vault.get("items", [])]
497
- for _it in _items_all:
498
- try:
499
- _pl = decrypt_item(st.session_state.keys["data_key"], _it)
500
- except Exception:
501
- continue
502
- _name = str(_pl.get("name", ""))
503
- _user = str(_pl.get("username", ""))
504
- _url = str(_pl.get("url", ""))
505
- _tags = ",".join(_pl.get("tags", []))
506
- hay = " ".join([_name, _user, _url, _tags]).lower()
507
- if LQ in hay:
508
- _matches.append((_it, _pl))
509
- if not _matches:
510
- st.info("No matches found in your vault. If this account isn't stored here yet, import or add it first.")
511
- else:
512
- for _it, _pl in _matches[:10]:
513
- with st.expander(f"{_pl.get('name') or '(unnamed)'} — {_pl.get('username','')} [{_it.type}]"):
514
- c1, c2 = st.columns([2, 1])
515
- with c1:
516
- st.write(f"**URL:** {_pl.get('url','')}")
517
- show_pw = st.checkbox("Show password", key=f"show_{_it.id}")
518
- pw_val = _pl.get('password','') if _it.type == 'login' else ''
519
- st.text_input("Password", value=pw_val,
520
- type=("default" if show_pw else "password"),
521
- key=f"quick_pw_{_it.id}")
522
- with c2:
523
- if st.button("Edit", key=f"quick_edit_{_it.id}"):
524
- st.session_state["edit_id"] = _it.id
525
- st.session_state["edit_payload"] = {
526
- **_pl,
527
- "type": _it.type,
528
- "name": _pl.get("name",""),
529
- "username": _pl.get("username",""),
530
- "url": _pl.get("url",""),
531
- "password": _pl.get("password",""),
532
- "note": _pl.get("note",""),
533
- "tags": ", ".join(_pl.get("tags", [])),
534
- }
535
- st.experimental_rerun()
536
-
537
- # Decrypt all for listing
538
- vault_dict = st.session_state.vault
539
- keys = st.session_state.keys
540
-
541
- items: List[VaultItem] = [VaultItem(**it) for it in vault_dict.get("items", [])]
542
- rows = []
543
- for it in items:
544
  try:
545
- payload = decrypt_item(keys["data_key"], it)
546
- rows.append({
547
- "_id": it.id,
548
- "type": it.type,
549
- "name": payload.get("name", ""),
550
- "username": payload.get("username", ""),
551
- "url": payload.get("url", ""),
552
- "password": payload.get("password", "") if it.type == "login" else "",
553
- "note": payload.get("note", "") if it.type == "note" else "",
554
- "tags": ", ".join(payload.get("tags", [])),
555
- "updated": time.strftime('%Y-%m-%d %H:%M', time.localtime(it.updated_at)),
556
- })
557
- except Exception:
558
- rows.append({"_id": it.id, "type": it.type, "name": "<decrypt error>", "username": "", "url": "", "password": "", "note": "", "tags": "", "updated": ""})
559
 
560
- # Filter main list
561
  if q.strip():
562
- Q = q.lower()
563
- rows = [r for r in rows if any(Q in (str(r[k]) or "").lower() for k in ["name", "username", "url", "tags", "note"]) ]
564
-
565
- st.caption(f"Items: {len(rows)}")
566
-
567
- # Render items
568
- for r in rows:
569
- with st.expander(f"{r['name'] or '(unnamed)'} — {r['username']} [{r['type']}] • updated {r['updated']}"):
570
- c1, c2 = st.columns([2, 1])
571
- with c1:
572
- st.write(f"**URL:** {r['url']}")
573
- if r['type'] == 'login':
574
- st.text_input("Password", value=r['password'], type="password", key=f"pw_{r['_id']}")
575
- if r['type'] == 'note':
576
- st.text_area("Note", value=r['note'], height=100, key=f"note_{r['_id']}")
577
- st.write(f"**Tags:** {r['tags']}")
578
- with c2:
579
- if st.button("Edit", key=f"edit_{r['_id']}"):
580
- st.session_state["edit_id"] = r['_id']
581
- st.session_state["edit_payload"] = r
582
- if st.button("Delete", key=f"del_{r['_id']}"):
583
- items = [it for it in items if it.id != r['_id']]
584
- vault_dict["items"] = [it.to_dict() for it in items]
585
- re_hmac(vault_dict, keys["mac_key"])
586
- safe_write_text(UPLOAD_DIR / "vault.smartpass.json", json.dumps(vault_dict, separators=(",", ":")))
587
- st.session_state.vault = vault_dict
588
- st.success("Deleted and saved.")
589
- st.experimental_rerun()
590
-
591
- # Add / Edit modals
592
- if st.session_state.get("add_type"):
593
- with st.modal("Add Item"):
594
- add_type = st.session_state.get("add_type")
595
- name = st.text_input("Name")
596
- username = st.text_input("Username") if add_type == "login" else ""
597
- url = st.text_input("URL") if add_type == "login" else ""
598
- password = st.text_input("Password", type="password") if add_type == "login" else ""
599
- note = st.text_area("Secure Note", height=120) if add_type == "note" else ""
600
- tags = st.text_input("Tags (comma-separated)")
601
- if st.button("Save"):
602
- payload = {
603
- "type": add_type,
604
- "name": name,
605
- "username": username,
606
- "url": url,
607
- "password": password,
608
- "note": note,
609
- "tags": [t.strip() for t in tags.split(",") if t.strip()],
610
- }
611
- new_item = encrypt_item(keys["data_key"], payload)
612
- items.append(new_item)
613
- vault_dict["items"] = [it.to_dict() for it in items]
614
- re_hmac(vault_dict, keys["mac_key"])
615
- safe_write_text(UPLOAD_DIR / "vault.smartpass.json", json.dumps(vault_dict, separators=(",", ":")))
616
- st.session_state.vault = vault_dict
617
- st.session_state["add_type"] = None
618
- st.success("Item added and saved.")
619
- st.experimental_rerun()
620
- if st.button("Cancel"):
621
- st.session_state["add_type"] = None
622
-
623
- if st.session_state.get("edit_id"):
624
- with st.modal("Edit Item"):
625
- eid = st.session_state.get("edit_id")
626
- original = next((it for it in items if it.id == eid), None)
627
- payload = st.session_state.get("edit_payload", {})
628
- etype = payload.get("type", "login")
629
- name = st.text_input("Name", value=payload.get("name", ""))
630
- username = st.text_input("Username", value=payload.get("username", "")) if etype == "login" else ""
631
- url = st.text_input("URL", value=payload.get("url", "")) if etype == "login" else ""
632
- password = st.text_input("Password", value=payload.get("password", ""), type="password") if etype == "login" else ""
633
- note = st.text_area("Secure Note", value=payload.get("note", ""), height=120) if etype == "note" else ""
634
- tags = st.text_input("Tags (comma-separated)", value=payload.get("tags", ""))
635
-
636
- if st.button("Save Changes"):
637
- new_payload = {
638
- "type": etype,
639
- "name": name,
640
- "username": username,
641
- "url": url,
642
- "password": password,
643
- "note": note,
644
- "tags": [t.strip() for t in tags.split(",") if t.strip()],
645
- }
646
- updated = update_item(keys["data_key"], original, new_payload)
647
- items = [updated if it.id == eid else it for it in items]
648
- vault_dict["items"] = [it.to_dict() for it in items]
649
- re_hmac(vault_dict, keys["mac_key"])
650
- safe_write_text(UPLOAD_DIR / "vault.smartpass.json", json.dumps(vault_dict, separators=(",", ":")))
651
- st.session_state.vault = vault_dict
652
- st.session_state["edit_id"] = None
653
- st.success("Updated and saved.")
654
- st.experimental_rerun()
655
- if st.button("Cancel"):
656
- st.session_state["edit_id"] = None
657
-
658
- # Security notes
659
- with st.expander("Security Notes & Tips"):
660
  st.markdown(
661
- f"""
662
- - **All crypto is local.** The vault JSON contains only ciphertext + metadata.
663
- - **Uploads dir:** `{UPLOAD_DIR}` (configurable via `UPLOAD_DIR`). Vault and imported files are saved here.
664
- - **Zero-knowledge:** The app never stores your master password; derivation happens in memory.
665
- - **Integrity:** Vault includes an HMAC to detect tampering/corruption.
666
- - **KDF tuning:** If your device is slow, reduce Argon2 memory/iterations in the sidebar.
667
- - **Backups:** Keep copies of your exported vault JSON in safe places.
668
  """
669
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import streamlit as st
2
+ import pandas as pd
3
+
4
+ st.set_page_config(page_title="Simple Password Lookup", page_icon="🔎", layout="centered")
5
+ st.title("🔎 Simple Password Lookup")
6
+ st.caption("Upload your Excel/CSV once → search by platform or username → reveal password.")
7
+
8
+ # ---------------- Helpers ----------------
9
+ ALIASES = {
10
+ "name": ["name", "title", "site", "account", "platform", "service", "app"],
11
+ "username": ["username", "user", "login", "email", "userid", "id"],
12
+ "url": ["url", "link", "website", "domain"],
13
+ "password": ["password", "pass", "pwd", "secret"],
14
+ "note": ["note", "notes", "remark", "remarks"],
15
+ }
16
+ EXPECTED = ["name", "username", "url", "password", "note"]
17
+
18
+
19
+ def standardize_columns(df: pd.DataFrame) -> pd.DataFrame:
20
+ # Normalize input headers to lowercase strings
21
+ df.columns = [str(c).strip().lower() for c in df.columns]
22
+ colmap = {}
23
+ # map aliases
24
+ for target, alias_list in ALIASES.items():
25
+ for c in df.columns:
26
+ if c in alias_list:
27
+ colmap[target] = c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  break
29
+ # ensure all expected exist
30
+ for col in EXPECTED:
31
  if col not in colmap:
32
  df[col] = ""
33
  colmap[col] = col
34
+ out = df[[colmap[c] for c in EXPECTED]].rename(columns={colmap[c]: c for c in colmap})
35
+ # sanitize to strings
36
+ for c in EXPECTED:
37
+ out[c] = out[c].astype(str).fillna("")
38
+ # drop fully empty rows
39
+ mask = (out["name"].str.strip() != "") | (out["username"].str.strip() != "") | (out["url"].str.strip() != "") | (out["password"].str.strip() != "")
40
+ return out[mask].reset_index(drop=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
 
43
+ def read_any(file) -> pd.DataFrame:
44
+ name = (file.name or "").lower()
45
+ if name.endswith(".csv"):
46
+ return pd.read_csv(file)
47
+ return pd.read_excel(file)
48
+
49
+ # ---------------- Upload once ----------------
50
+ if "creds" not in st.session_state:
51
+ st.subheader("1) Upload your Excel/CSV")
52
+ up = st.file_uploader("Choose file", type=["xlsx", "xls", "csv"], accept_multiple_files=False)
53
+ if up is None:
54
+ st.stop()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  try:
56
+ df = read_any(up)
57
+ df = standardize_columns(df)
58
+ st.session_state.creds = df
59
+ st.success(f"Loaded {len(df)} entries.")
60
+ except Exception as e:
61
+ st.error(f"Failed to read file: {e}")
62
+ st.stop()
63
+
64
+ # ---------------- Search ----------------
65
+ st.subheader("2) Find your password")
66
+ q = st.text_input("Search by platform/site/username", placeholder="e.g., netflix, gmail.com, your@email.com")
 
 
 
67
 
 
68
  if q.strip():
69
+ Q = q.lower().strip()
70
+ df = st.session_state.creds
71
+ mask = (
72
+ df["name"].str.lower().str.contains(Q)
73
+ | df["username"].str.lower().str.contains(Q)
74
+ | df["url"].str.lower().str.contains(Q)
75
+ )
76
+ results = df[mask]
77
+ if results.empty:
78
+ st.warning("No matches. Try another keyword or check your file headers.")
79
+ else:
80
+ st.caption(f"Matches: {len(results)} (showing up to 50)")
81
+ for idx, row in results.head(50).iterrows():
82
+ title = row['name'] or row['username'] or row['url']
83
+ with st.expander(f"{title} — {row['username']} | {row['url']}"):
84
+ show = st.checkbox("Show password", key=f"show_{idx}")
85
+ st.text_input("Password", value=row["password"], type=("default" if show else "password"), key=f"pw_{idx}")
86
+ if str(row.get("note", "")).strip():
87
+ st.caption("Note: " + str(row["note"]))
88
+ else:
89
+ st.caption("Type a keyword above to search.")
90
+
91
+ # ---------------- Tips ----------------
92
+ with st.expander("Tips"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  st.markdown(
94
+ """
95
+ - Required columns (case-insensitive, aliases ok): name, username, url, password. Optional: note.
96
+ - Data is kept only **in memory** for this session. Re-upload if the app restarts.
97
+ - For large files, use CSV for faster upload.
 
 
 
98
  """
99
  )