SmartHeal commited on
Commit
95e0c4c
·
verified ·
1 Parent(s): 5cb7b2c

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. src/streamlit_app.py +495 -38
src/streamlit_app.py CHANGED
@@ -1,40 +1,497 @@
1
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
 
 
 
 
 
4
  import streamlit as st
5
 
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
 
11
+ # ---- Crypto deps (install: pip install cryptography argon2-cffi==23.1.0) ----
12
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
13
+ from cryptography.hazmat.primitives import hashes
14
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
15
+ from cryptography.hazmat.primitives.hmac import HMAC
16
+ from cryptography.hazmat.backends import default_backend
17
+ from argon2.low_level import hash_secret_raw, Type
18
+
19
+ APP_TITLE = "SmartPass — Local, Zero‑Knowledge Password Manager"
20
+ VERSION = 1
21
+
22
+ # -------------------------- Utility helpers --------------------------
23
+
24
+ def b64e(b: bytes) -> str:
25
+ return base64.b64encode(b).decode("utf-8")
26
+
27
+
28
+ def b64d(s: str) -> bytes:
29
+ return base64.b64decode(s.encode("utf-8"))
30
+
31
+
32
+ def hkdf_expand(key_material: bytes, info: bytes, length: int = 32, salt: Optional[bytes] = None) -> bytes:
33
+ hkdf = HKDF(
34
+ algorithm=hashes.SHA256(),
35
+ length=length,
36
+ salt=salt,
37
+ info=info,
38
+ backend=default_backend(),
39
+ )
40
+ return hkdf.derive(key_material)
41
+
42
+
43
+ # -------------------------- KDF & Keys --------------------------
44
+ @dataclass
45
+ class KDFParams:
46
+ algo: str = "argon2id"
47
+ salt_b64: str = ""
48
+ m_cost_kib: int = 131072 # 128 MiB
49
+ t_cost: int = 3
50
+ parallelism: int = 1
51
+ hash_len: int = 32
52
+
53
+ def to_dict(self):
54
+ d = asdict(self)
55
+ return d
56
+
57
+
58
+ @dataclass
59
+ class WrappedDataKey:
60
+ nonce_b64: str
61
+ ct_b64: str
62
+
63
+ def to_dict(self):
64
+ return asdict(self)
65
+
66
+
67
+ @dataclass
68
+ class VaultItem:
69
+ id: str
70
+ type: str # "login" | "note"
71
+ enc_blob_b64: str
72
+ nonce_b64: str
73
+ created_at: float
74
+ updated_at: float
75
+
76
+ def to_dict(self):
77
+ return asdict(self)
78
+
79
+
80
+ @dataclass
81
+ class Vault:
82
+ version: int
83
+ kdf: KDFParams
84
+ data_key_wrapped: WrappedDataKey
85
+ items: List[VaultItem]
86
+ integrity_hmac_b64: str
87
+
88
+ def to_dict(self):
89
+ return {
90
+ "version": self.version,
91
+ "kdf": self.kdf.to_dict(),
92
+ "data_key_wrapped": self.data_key_wrapped.to_dict(),
93
+ "items": [it.to_dict() for it in self.items],
94
+ "integrity_hmac_b64": self.integrity_hmac_b64,
95
+ }
96
+
97
+
98
+ # Argon2id derivation → 32B master key bytes
99
+
100
+ def derive_master_key(password: str, kdf: KDFParams) -> bytes:
101
+ if kdf.algo != "argon2id":
102
+ raise ValueError("Unsupported KDF")
103
+ salt = b64d(kdf.salt_b64)
104
+ mk = hash_secret_raw(
105
+ secret=password.encode("utf-8"),
106
+ salt=salt,
107
+ time_cost=kdf.t_cost,
108
+ memory_cost=kdf.m_cost_kib,
109
+ parallelism=kdf.parallelism,
110
+ hash_len=kdf.hash_len,
111
+ type=Type.ID,
112
+ )
113
+ return mk
114
+
115
+
116
+ # -------------------------- Encryption helpers --------------------------
117
+
118
+ def aesgcm_encrypt(key: bytes, plaintext: bytes, aad: Optional[bytes] = None) -> Dict[str, str]:
119
+ aes = AESGCM(key)
120
+ nonce = secrets.token_bytes(12)
121
+ ct = aes.encrypt(nonce, plaintext, aad)
122
+ return {"nonce_b64": b64e(nonce), "ct_b64": b64e(ct)}
123
+
124
+
125
+ def aesgcm_decrypt(key: bytes, nonce_b64: str, ct_b64: str, aad: Optional[bytes] = None) -> bytes:
126
+ aes = AESGCM(key)
127
+ return aes.decrypt(b64d(nonce_b64), b64d(ct_b64), aad)
128
+
129
+
130
+ # -------------------------- Integrity (HMAC) --------------------------
131
+
132
+ def canonical_vault_for_hmac(v: Dict[str, Any]) -> bytes:
133
+ # Exclude integrity field itself to avoid recursion
134
+ tmp = dict(v)
135
+ tmp.pop("integrity_hmac_b64", None)
136
+ # Stable ordering
137
+ return json.dumps(tmp, sort_keys=True, separators=(",", ":")).encode("utf-8")
138
+
139
+
140
+ def compute_hmac(mac_key: bytes, v_dict: Dict[str, Any]) -> str:
141
+ h = HMAC(mac_key, hashes.SHA256(), backend=default_backend())
142
+ h.update(canonical_vault_for_hmac(v_dict))
143
+ return b64e(h.finalize())
144
+
145
+
146
+ def verify_hmac(mac_key: bytes, v_dict: Dict[str, Any]) -> bool:
147
+ try:
148
+ expected = v_dict.get("integrity_hmac_b64", "")
149
+ h = HMAC(mac_key, hashes.SHA256(), backend=default_backend())
150
+ h.update(canonical_vault_for_hmac(v_dict))
151
+ h.verify(b64d(expected))
152
+ return True
153
+ except Exception:
154
+ return False
155
+
156
+
157
+ # -------------------------- Vault Ops --------------------------
158
+
159
+ def new_vault(password: str) -> Vault:
160
+ kdf = KDFParams()
161
+ kdf.salt_b64 = b64e(secrets.token_bytes(16))
162
+ master_key = derive_master_key(password, kdf)
163
+
164
+ # Derive subkeys
165
+ wrap_key = hkdf_expand(master_key, b"wrap-key") # for wrapping data key
166
+ mac_key = hkdf_expand(master_key, b"vault-mac") # for HMAC integrity
167
+
168
+ data_key = secrets.token_bytes(32)
169
+ wrapped = aesgcm_encrypt(wrap_key, data_key, aad=b"SmartPass:data-key")
170
+ vault = Vault(
171
+ version=VERSION,
172
+ kdf=kdf,
173
+ data_key_wrapped=WrappedDataKey(**wrapped),
174
+ items=[],
175
+ integrity_hmac_b64="",
176
+ )
177
+ # Compute initial HMAC
178
+ vdict = vault.to_dict()
179
+ vdict["integrity_hmac_b64"] = ""
180
+ hmac_b64 = compute_hmac(mac_key, vdict)
181
+ vault.integrity_hmac_b64 = hmac_b64
182
+ # Clear secrets from locals (best-effort)
183
+ return vault
184
+
185
+
186
+ def unlock_vault(vault_dict: Dict[str, Any], password: str) -> Dict[str, Any]:
187
+ # Returns {"data_key": bytes, "mac_key": bytes}
188
+ kdf = KDFParams(**vault_dict["kdf"]) if isinstance(vault_dict["kdf"], dict) else vault_dict["kdf"]
189
+ master_key = derive_master_key(password, kdf)
190
+ wrap_key = hkdf_expand(master_key, b"wrap-key")
191
+ mac_key = hkdf_expand(master_key, b"vault-mac")
192
+
193
+ # Verify integrity first
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
+
197
+ w = vault_dict["data_key_wrapped"]
198
+ data_key = aesgcm_decrypt(wrap_key, w["nonce_b64"], w["ct_b64"], aad=b"SmartPass:data-key")
199
+ return {"data_key": data_key, "mac_key": mac_key}
200
+
201
+
202
+ def re_hmac(vault_dict: Dict[str, Any], mac_key: bytes) -> None:
203
+ vault_dict["integrity_hmac_b64"] = ""
204
+ vault_dict["integrity_hmac_b64"] = compute_hmac(mac_key, vault_dict)
205
+
206
+
207
+ # -------------------------- Item Ops --------------------------
208
+
209
+ def encrypt_item(data_key: bytes, payload: Dict[str, Any]) -> VaultItem:
210
+ now = time.time()
211
+ enc = aesgcm_encrypt(data_key, json.dumps(payload).encode("utf-8"))
212
+ return VaultItem(
213
+ id=secrets.token_hex(8),
214
+ type=payload.get("type", "login"),
215
+ enc_blob_b64=enc["ct_b64"],
216
+ nonce_b64=enc["nonce_b64"],
217
+ created_at=now,
218
+ updated_at=now,
219
+ )
220
+
221
+
222
+ def decrypt_item(data_key: bytes, it: VaultItem) -> Dict[str, Any]:
223
+ pt = aesgcm_decrypt(data_key, it.nonce_b64, it.enc_blob_b64)
224
+ return json.loads(pt.decode("utf-8"))
225
+
226
+
227
+ def update_item(data_key: bytes, it: VaultItem, payload: Dict[str, Any]) -> VaultItem:
228
+ enc = aesgcm_encrypt(data_key, json.dumps(payload).encode("utf-8"))
229
+ it.enc_blob_b64 = enc["ct_b64"]
230
+ it.nonce_b64 = enc["nonce_b64"]
231
+ it.updated_at = time.time()
232
+ return it
233
+
234
+
235
+ # -------------------------- Streamlit UI --------------------------
236
+ st.set_page_config(page_title=APP_TITLE, page_icon="🔐", layout="wide")
237
+
238
+ if "vault" not in st.session_state:
239
+ st.session_state.vault: Optional[Dict[str, Any]] = None
240
+ if "unlocked" not in st.session_state:
241
+ st.session_state.unlocked = False
242
+ if "keys" not in st.session_state:
243
+ st.session_state.keys = None # {data_key, mac_key}
244
+ if "last_active" not in st.session_state:
245
+ st.session_state.last_active = time.time()
246
+
247
+
248
+ def touch():
249
+ st.session_state.last_active = time.time()
250
+
251
+
252
+ def auto_lock(minutes: int):
253
+ if st.session_state.unlocked:
254
+ if time.time() - st.session_state.last_active > minutes * 60:
255
+ st.warning("Auto-locked due to inactivity.")
256
+ do_lock()
257
+
258
+
259
+ def do_lock():
260
+ st.session_state.unlocked = False
261
+ st.session_state.keys = None
262
+ touch()
263
+
264
+
265
+ # Sidebar: Create / Open / Lock / Export
266
+ with st.sidebar:
267
+ st.title("🔐 SmartPass")
268
+ st.caption("Local, file-based, zero-knowledge vault. No servers.")
269
+
270
+ # Auto-lock
271
+ lock_minutes = st.number_input("Auto-lock (minutes)", min_value=1, max_value=120, value=5)
272
+
273
+ if st.session_state.unlocked:
274
+ if st.button("Lock Now", use_container_width=True):
275
+ do_lock()
276
+ auto_lock(lock_minutes)
277
+
278
+ st.divider()
279
+ st.subheader("Open Vault")
280
+ up = st.file_uploader("Upload existing vault (.json)", type=["json"], accept_multiple_files=False)
281
+ password_open = st.text_input("Master password", type="password")
282
+ if st.button("Unlock", use_container_width=True):
283
+ if up is None:
284
+ st.error("Please upload a vault file.")
285
+ elif not password_open:
286
+ st.error("Please enter your master password.")
287
+ else:
288
+ try:
289
+ vault_dict = json.loads(up.getvalue().decode("utf-8"))
290
+ keys = unlock_vault(vault_dict, password_open)
291
+ st.session_state.vault = vault_dict
292
+ st.session_state.keys = keys
293
+ st.session_state.unlocked = True
294
+ touch()
295
+ st.success("Vault unlocked.")
296
+ except Exception as e:
297
+ st.session_state.unlocked = False
298
+ st.error(f"Failed to unlock: {e}")
299
+
300
+ st.divider()
301
+ st.subheader("Create New Vault")
302
+ new_pw = st.text_input("New master password", type="password")
303
+ new_pw2 = st.text_input("Confirm password", type="password")
304
+ with st.expander("Advanced KDF (Argon2id)"):
305
+ m_mib = st.slider("Memory (MiB)", 64, 512, 128, step=32)
306
+ t_cost = st.slider("Iterations", 1, 5, 3)
307
+ par = st.slider("Parallelism", 1, 4, 1)
308
+ if st.button("Create Vault", use_container_width=True):
309
+ if not new_pw:
310
+ st.error("Master password required.")
311
+ elif new_pw != new_pw2:
312
+ st.error("Passwords do not match.")
313
+ else:
314
+ v = new_vault(new_pw)
315
+ # override KDF knobs from UI
316
+ v.kdf.m_cost_kib = m_mib * 1024
317
+ v.kdf.t_cost = t_cost
318
+ v.kdf.parallelism = par
319
+ # Recompute HMAC with same master pw but updated kdf params
320
+ mk = derive_master_key(new_pw, v.kdf)
321
+ mac_key = hkdf_expand(mk, b"vault-mac")
322
+ vdict = v.to_dict()
323
+ vdict["integrity_hmac_b64"] = ""
324
+ vdict["integrity_hmac_b64"] = compute_hmac(mac_key, vdict)
325
+ st.session_state.vault = vdict
326
+ st.session_state.keys = unlock_vault(vdict, new_pw)
327
+ st.session_state.unlocked = True
328
+ touch()
329
+ st.success("New vault created and unlocked.")
330
+
331
+ st.divider()
332
+ st.subheader("Export")
333
+ if st.session_state.vault is not None:
334
+ export_json = json.dumps(st.session_state.vault, separators=(",", ":"))
335
+ st.download_button(
336
+ label="Download vault JSON",
337
+ data=export_json,
338
+ file_name="vault.smartpass.json",
339
+ mime="application/json",
340
+ use_container_width=True,
341
+ )
342
+
343
+ # -------------------------- Main Area --------------------------
344
+ st.title(APP_TITLE)
345
+
346
+ if not st.session_state.unlocked:
347
+ st.info("Unlock or create a vault from the left sidebar to begin.")
348
+ st.stop()
349
+
350
+ # Search and actions
351
+ col1, col2, col3, col4 = st.columns([3, 1, 1, 1])
352
+ with col1:
353
+ q = st.text_input("Search (name / username / url / tags)", value="")
354
+ with col2:
355
+ if st.button("Add Login"):
356
+ st.session_state["add_type"] = "login"
357
+ with col3:
358
+ if st.button("Add Secure Note"):
359
+ st.session_state["add_type"] = "note"
360
+ with col4:
361
+ if st.button("Lock"):
362
+ do_lock()
363
+ st.experimental_rerun()
364
+
365
+ touch()
366
+
367
+ # Items table
368
+ vault_dict = st.session_state.vault
369
+ keys = st.session_state.keys
370
+
371
+ # Decrypt all (in-memory only)
372
+ items: List[VaultItem] = [VaultItem(**it) for it in vault_dict.get("items", [])]
373
+ rows = []
374
+ for it in items:
375
+ try:
376
+ payload = decrypt_item(keys["data_key"], it)
377
+ rows.append({
378
+ "_id": it.id,
379
+ "type": it.type,
380
+ "name": payload.get("name", ""),
381
+ "username": payload.get("username", ""),
382
+ "url": payload.get("url", ""),
383
+ "password": payload.get("password", "") if it.type == "login" else "",
384
+ "note": payload.get("note", "") if it.type == "note" else "",
385
+ "tags": ", ".join(payload.get("tags", [])),
386
+ "updated": time.strftime('%Y-%m-%d %H:%M', time.localtime(it.updated_at)),
387
+ })
388
+ except Exception:
389
+ rows.append({"_id": it.id, "type": it.type, "name": "<decrypt error>", "username": "", "url": "", "password": "", "note": "", "tags": "", "updated": ""})
390
+
391
+ # Filter
392
+ if q.strip():
393
+ Q = q.lower()
394
+ rows = [r for r in rows if any(Q in (str(r[k]) or "").lower() for k in ["name", "username", "url", "tags", "note"]) ]
395
+
396
+ st.caption(f"Items: {len(rows)}")
397
+
398
+ # Render items
399
+ for r in rows:
400
+ with st.expander(f"{r['name'] or '(unnamed)'} — {r['username']} [{r['type']}] • updated {r['updated']}"):
401
+ c1, c2 = st.columns([2, 1])
402
+ with c1:
403
+ st.write(f"**URL:** {r['url']}")
404
+ if r['type'] == 'login':
405
+ st.text_input("Password", value=r['password'], type="password", key=f"pw_{r['_id']}")
406
+ if r['type'] == 'note':
407
+ st.text_area("Note", value=r['note'], height=100, key=f"note_{r['_id']}")
408
+ st.write(f"**Tags:** {r['tags']}")
409
+ with c2:
410
+ if st.button("Edit", key=f"edit_{r['_id']}"):
411
+ st.session_state["edit_id"] = r['_id']
412
+ st.session_state["edit_payload"] = r
413
+ if st.button("Delete", key=f"del_{r['_id']}"):
414
+ items = [it for it in items if it.id != r['_id']]
415
+ vault_dict["items"] = [it.to_dict() for it in items]
416
+ re_hmac(vault_dict, keys["mac_key"])
417
+ st.session_state.vault = vault_dict
418
+ st.success("Deleted.")
419
+ st.experimental_rerun()
420
+
421
+ # Add / Edit forms
422
+ if st.session_state.get("add_type"):
423
+ with st.modal("Add Item"):
424
+ add_type = st.session_state.get("add_type")
425
+ name = st.text_input("Name")
426
+ username = st.text_input("Username") if add_type == "login" else ""
427
+ url = st.text_input("URL") if add_type == "login" else ""
428
+ password = st.text_input("Password", type="password") if add_type == "login" else ""
429
+ note = st.text_area("Secure Note", height=120) if add_type == "note" else ""
430
+ tags = st.text_input("Tags (comma-separated)")
431
+ if st.button("Save"):
432
+ payload = {
433
+ "type": add_type,
434
+ "name": name,
435
+ "username": username,
436
+ "url": url,
437
+ "password": password,
438
+ "note": note,
439
+ "tags": [t.strip() for t in tags.split(",") if t.strip()],
440
+ }
441
+ new_item = encrypt_item(keys["data_key"], payload)
442
+ items.append(new_item)
443
+ vault_dict["items"] = [it.to_dict() for it in items]
444
+ re_hmac(vault_dict, keys["mac_key"])
445
+ st.session_state.vault = vault_dict
446
+ st.session_state["add_type"] = None
447
+ st.success("Item added.")
448
+ st.experimental_rerun()
449
+ if st.button("Cancel"):
450
+ st.session_state["add_type"] = None
451
+
452
+ if st.session_state.get("edit_id"):
453
+ with st.modal("Edit Item"):
454
+ eid = st.session_state.get("edit_id")
455
+ original = next((it for it in items if it.id == eid), None)
456
+ payload = st.session_state.get("edit_payload", {})
457
+ etype = payload.get("type", "login")
458
+ name = st.text_input("Name", value=payload.get("name", ""))
459
+ username = st.text_input("Username", value=payload.get("username", "")) if etype == "login" else ""
460
+ url = st.text_input("URL", value=payload.get("url", "")) if etype == "login" else ""
461
+ password = st.text_input("Password", value=payload.get("password", ""), type="password") if etype == "login" else ""
462
+ note = st.text_area("Secure Note", value=payload.get("note", ""), height=120) if etype == "note" else ""
463
+ tags = st.text_input("Tags (comma-separated)", value=payload.get("tags", ""))
464
+
465
+ if st.button("Save Changes"):
466
+ new_payload = {
467
+ "type": etype,
468
+ "name": name,
469
+ "username": username,
470
+ "url": url,
471
+ "password": password,
472
+ "note": note,
473
+ "tags": [t.strip() for t in tags.split(",") if t.strip()],
474
+ }
475
+ updated = update_item(keys["data_key"], original, new_payload)
476
+ # Replace in list
477
+ items = [updated if it.id == eid else it for it in items]
478
+ vault_dict["items"] = [it.to_dict() for it in items]
479
+ re_hmac(vault_dict, keys["mac_key"])
480
+ st.session_state.vault = vault_dict
481
+ st.session_state["edit_id"] = None
482
+ st.success("Updated.")
483
+ st.experimental_rerun()
484
+ if st.button("Cancel"):
485
+ st.session_state["edit_id"] = None
486
+
487
+ # Security footnotes
488
+ with st.expander("Security Notes & Tips"):
489
+ st.markdown(
490
+ """
491
+ - **All encryption/decryption happens locally.** Your vault is a JSON file that only contains ciphertext. Keep backups.
492
+ - **Zero-knowledge:** The app never stores your master password. Only a key derived via **Argon2id** is used transiently in memory.
493
+ - **Integrity:** The vault includes an HMAC to detect tampering/corruption. Wrong password also fails integrity.
494
+ - **Clipboard caution:** This demo does not auto-copy passwords to avoid OS clipboard risks.
495
+ - **KDF tuning:** If your device is slow, reduce memory/iterations in the sidebar's Advanced KDF.
496
+ """
497
+ )