From 321cad0afdf3d9f8f6f9433d2673d9f75feed4a9 Mon Sep 17 00:00:00 2001 From: Syme-6005 Date: Mon, 11 May 2026 17:27:03 -0400 Subject: [PATCH 01/42] update (#10) --- cipher.html | 1647 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 993 insertions(+), 654 deletions(-) diff --git a/cipher.html b/cipher.html index 9f16f4f..5ba7b3a 100644 --- a/cipher.html +++ b/cipher.html @@ -3,7 +3,7 @@ -ASCII Cipher — Secure +ASCII Cipher Vault -
+ +
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
Your password never leaves your device — only a derived hash is sent.
+
+
+ + + + + +
Ctrl+Enter run · Ctrl+S swap · Ctrl+L clear · Ctrl+G generate key
- cross-compatible with the Python build · format salt:ct:tag + zero-knowledge vault · cross-compatible with Python build · format salt:ct:tag
+ + + + + + + + +
- - +async function saveVaultItem() { + const label = document.getElementById("vault-label-input").value.trim(); + const payload = document.getElementById("vault-payload-input").value.trim(); + const pinned = document.getElementById("vault-pinned-input").checked; + if(!label) { toast("Label required","error"); return; } + if(!payload) { toast("Payload required","error"); return; } + if(!masterPass) { toast("No master passphrase — re-login From 9859d9c99b221357434c0ebb5dcc868c10e20689 Mon Sep 17 00:00:00 2001 From: Syme-6005 Date: Tue, 12 May 2026 21:45:03 +0100 Subject: [PATCH 02/42] fix: make sit work (probs) (#11) --- backend/main.py | 2867 ++++++++++++++++++++++------------------------- 1 file changed, 1329 insertions(+), 1538 deletions(-) diff --git a/backend/main.py b/backend/main.py index eba26ba..f0bd45d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,1543 +1,1334 @@ -"""ASCII Cipher Vault — FastAPI backend. - -Zero-knowledge architecture: server stores only a per-user salt, an Argon2id hash -of the client-derived auth_hash, and AES-GCM ciphertext for vault items. The -master password and the vault key never leave the browser. -""" -import os -import re -import time -import secrets -import hmac -import hashlib -import logging -import sqlite3 -from contextlib import contextmanager -from pathlib import Path -from typing import Optional - -from argon2 import PasswordHasher -from argon2.exceptions import VerifyMismatchError, InvalidHash -from fastapi import FastAPI, Request, Response, HTTPException, Depends -from fastapi.responses import FileResponse -from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel, Field - -ROOT = Path(__file__).resolve().parent.parent -DB_PATH = Path(os.environ.get("CIPHER_DB", str(ROOT / "data" / "cipher.db"))) -STATIC_DIR = Path(os.environ.get("CIPHER_STATIC", str(ROOT / "static"))) -SECRET_KEY = os.environ.get("CIPHER_SECRET") -if not SECRET_KEY or SECRET_KEY == "changeme": - raise RuntimeError("CIPHER_SECRET env var required (run: openssl rand -hex 32)") - -REGISTRATION_TOKEN = os.environ.get("CIPHER_REGISTRATION_TOKEN", "") -TRUST_PROXY = os.environ.get("CIPHER_TRUST_PROXY", "1") == "1" - -SESSION_TTL = 30 * 24 * 60 * 60 -SESSION_COOKIE = "sid" -CSRF_COOKIE = "csrf" -CSRF_HEADER = "X-CSRF-Token" -PBKDF2_ITERATIONS = 200_000 -EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") - -# Group invite tokens expire after 7 days -GROUP_INVITE_TTL = 7 * 24 * 60 * 60 - -logger = logging.getLogger(__name__) - -hasher = PasswordHasher(time_cost=3, memory_cost=64 * 1024, parallelism=2) - -SCHEMA = """ -PRAGMA journal_mode=WAL; -PRAGMA foreign_keys=ON; - -CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - email TEXT UNIQUE NOT NULL, - auth_salt BLOB NOT NULL, - auth_hash TEXT NOT NULL, - created_at INTEGER NOT NULL, - last_login_at INTEGER -); - -CREATE TABLE IF NOT EXISTS sessions ( - id TEXT PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - csrf TEXT NOT NULL, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - user_agent TEXT, - ip TEXT -); -CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); - -CREATE TABLE IF NOT EXISTS vault_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - label_ct TEXT NOT NULL, - payload_ct TEXT NOT NULL, - pinned INTEGER NOT NULL DEFAULT 0, - created_at INTEGER NOT NULL, - updated_at INTEGER NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_vault_user ON vault_items(user_id); - -CREATE TABLE IF NOT EXISTS history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - op TEXT NOT NULL, - preview_ct TEXT NOT NULL, - created_at INTEGER NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_history_user_time ON history(user_id, created_at DESC); - -CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - sender_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - recipient_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - ciphertext TEXT NOT NULL, - hint TEXT, - reply_to_id INTEGER REFERENCES messages(id) ON DELETE SET NULL, - created_at INTEGER NOT NULL, - read_at INTEGER, - deleted_by_sender INTEGER NOT NULL DEFAULT 0, - deleted_by_recipient INTEGER NOT NULL DEFAULT 0 -); -CREATE INDEX IF NOT EXISTS idx_messages_recipient ON messages(recipient_id, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_messages_sender ON messages(sender_id, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_messages_pair ON messages(sender_id, recipient_id, created_at DESC); - -CREATE TABLE IF NOT EXISTS groups ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - salt BLOB NOT NULL, - verifier_hash TEXT NOT NULL, - created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, - created_at INTEGER NOT NULL -); - -CREATE TABLE IF NOT EXISTS group_members ( - group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - wrapped_key TEXT NOT NULL, - joined_at INTEGER NOT NULL, - last_read_at INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY (group_id, user_id) -); -CREATE INDEX IF NOT EXISTS idx_group_members_user ON group_members(user_id); - -CREATE TABLE IF NOT EXISTS group_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, - sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - ciphertext TEXT NOT NULL, - hint TEXT, - reply_to_id INTEGER REFERENCES group_messages(id) ON DELETE SET NULL, - created_at INTEGER NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_group_messages_group ON group_messages(group_id, created_at DESC); - -CREATE TABLE IF NOT EXISTS push_subscriptions ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - subscription_json TEXT NOT NULL, - user_agent TEXT, - created_at INTEGER NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id); - -CREATE TABLE IF NOT EXISTS blocked_users ( - blocker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - blocked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_at INTEGER NOT NULL, - PRIMARY KEY (blocker_id, blocked_id) -); -CREATE INDEX IF NOT EXISTS idx_blocked_users_blocked ON blocked_users(blocked_id); - -CREATE TABLE IF NOT EXISTS reports ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - reporter_id INTEGER NOT NULL REFERENCES users(id) ON DELETE SET NULL, - reported_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, - message_id INTEGER REFERENCES messages(id) ON DELETE SET NULL, - group_message_id INTEGER REFERENCES group_messages(id) ON DELETE SET NULL, - reason TEXT NOT NULL, - details TEXT, - created_at INTEGER NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_reports_created ON reports(created_at DESC); -CREATE INDEX IF NOT EXISTS idx_reports_reported_user ON reports(reported_user_id); - -CREATE TABLE IF NOT EXISTS archived_messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - original_msg_id INTEGER NOT NULL, - sender_id INTEGER, - recipient_id INTEGER, - ciphertext TEXT NOT NULL, - hint TEXT, - created_at INTEGER NOT NULL, - archived_at INTEGER NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_archived_messages_sender ON archived_messages(sender_id); -CREATE INDEX IF NOT EXISTS idx_archived_messages_recipient ON archived_messages(recipient_id); - -CREATE TABLE IF NOT EXISTS login_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - ip TEXT, - user_agent TEXT, - created_at INTEGER NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_login_history_user ON login_history(user_id, created_at DESC); - -CREATE TABLE IF NOT EXISTS media ( - id TEXT PRIMARY KEY, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - size_bytes INTEGER NOT NULL, - created_at INTEGER NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_media_user ON media(user_id); - --- Tracks which users are authorised to download a given media file. --- The uploader is inserted automatically on upload; additional recipients --- are added when the uploader sends a message that references the media. -CREATE TABLE IF NOT EXISTS media_access ( - media_id TEXT NOT NULL REFERENCES media(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - granted_at INTEGER NOT NULL, - PRIMARY KEY (media_id, user_id) -); -CREATE INDEX IF NOT EXISTS idx_media_access_user ON media_access(user_id); - --- Pending group invitations. The inviter pre-wraps the group key for the --- invitee and stores it here; the invitee redeems the token to join without --- needing the group passphrase. -CREATE TABLE IF NOT EXISTS group_invites ( - token TEXT PRIMARY KEY, - group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, - inviter_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - invitee_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - wrapped_key TEXT NOT NULL, - created_at INTEGER NOT NULL, - expires_at INTEGER NOT NULL, - accepted_at INTEGER, - UNIQUE (group_id, invitee_id) -- one pending invite per (group, invitee) pair -); -CREATE INDEX IF NOT EXISTS idx_group_invites_invitee ON group_invites(invitee_id, expires_at); -CREATE INDEX IF NOT EXISTS idx_group_invites_group ON group_invites(group_id); -""" - - -def _migrate(conn): - """Apply additive column migrations safely.""" - migrations = [ - ("messages", "reply_to_id", "INTEGER REFERENCES messages(id) ON DELETE SET NULL"), - ("group_messages", "reply_to_id", "INTEGER REFERENCES group_messages(id) ON DELETE SET NULL"), - ("push_subscriptions", None, None), # table-level check only - ] - existing_tables = {r[0] for r in conn.execute( - "SELECT name FROM sqlite_master WHERE type='table'" - ).fetchall()} - for table, col, coldef in migrations: - if col is None: - continue - if table not in existing_tables: - continue - cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()} - if col not in cols: - conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {coldef}") - - -def init_db(): - DB_PATH.parent.mkdir(parents=True, exist_ok=True) - with sqlite3.connect(DB_PATH) as conn: - conn.executescript(SCHEMA) - _migrate(conn) - - -@contextmanager -def db(): - conn = sqlite3.connect(DB_PATH, isolation_level=None) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA foreign_keys=ON") - try: - yield conn - finally: - conn.close() - - -_rl_store: dict[str, list[float]] = {} - -def rate_limit(key: str, limit: int, window: float): - now = time.time() - bucket = _rl_store.setdefault(key, []) - cutoff = now - window - while bucket and bucket[0] < cutoff: - bucket.pop(0) - if len(bucket) >= limit: - raise HTTPException(429, "Too many requests") - bucket.append(now) - - -def client_ip(request: Request) -> str: - if TRUST_PROXY: - fwd = request.headers.get("x-forwarded-for") - if fwd: - return fwd.split(",")[0].strip() - return request.client.host if request.client else "unknown" - - -def is_https(request: Request) -> bool: - if TRUST_PROXY: - proto = request.headers.get("x-forwarded-proto") - if proto: - return proto == "https" - return request.url.scheme == "https" - - -def deterministic_salt(email: str) -> str: - h = hmac.new(SECRET_KEY.encode(), b"preflight:" + email.encode(), hashlib.sha256) - return h.digest()[:16].hex() - - -def set_cookie(response: Response, name: str, value: str, request: Request, http_only: bool): - response.set_cookie( - key=name, value=value, max_age=SESSION_TTL, - httponly=http_only, secure=is_https(request), - samesite="lax", path="/", - ) - - -def make_session(user_id: int, request: Request, response: Response): - sid = secrets.token_urlsafe(32) - csrf = secrets.token_urlsafe(24) - now = int(time.time()) - with db() as conn: - conn.execute( - "INSERT INTO sessions (id, user_id, csrf, created_at, expires_at, user_agent, ip) VALUES (?,?,?,?,?,?,?)", - (sid, user_id, csrf, now, now + SESSION_TTL, - (request.headers.get("user-agent") or "")[:300], client_ip(request)), - ) - set_cookie(response, SESSION_COOKIE, sid, request, http_only=True) - set_cookie(response, CSRF_COOKIE, csrf, request, http_only=False) - - -def current_user(request: Request) -> Optional[sqlite3.Row]: - sid = request.cookies.get(SESSION_COOKIE) - if not sid: - return None - with db() as conn: - row = conn.execute( - """SELECT u.id, u.email, u.auth_salt, u.auth_hash, u.created_at, u.last_login_at, - s.id AS sess_id, s.csrf AS sess_csrf, s.expires_at AS sess_exp - FROM sessions s JOIN users u ON u.id = s.user_id - WHERE s.id = ?""", - (sid,) - ).fetchone() - if not row or row["sess_exp"] < int(time.time()): - return None - return row - - -def require_user(request: Request): - user = current_user(request) - if not user: - raise HTTPException(401, "Not authenticated") - return user - - -def require_csrf(request: Request, user): - if request.method in ("GET", "HEAD", "OPTIONS"): - return - header = request.headers.get(CSRF_HEADER) - cookie = request.cookies.get(CSRF_COOKIE) - if not header or not cookie: - raise HTTPException(403, "CSRF token missing") - if not secrets.compare_digest(header, cookie): - raise HTTPException(403, "CSRF token mismatch") - if not secrets.compare_digest(header, user["sess_csrf"]): - raise HTTPException(403, "CSRF token invalid") - - -def auth_dep(request: Request): - user = require_user(request) - require_csrf(request, user) - return user - - -class PreflightIn(BaseModel): - email: str = Field(min_length=3, max_length=254) - - -class RegisterIn(BaseModel): - email: str = Field(min_length=3, max_length=254) - authSalt: str = Field(pattern="^[0-9a-fA-F]{32}$") - authHash: str = Field(pattern="^[0-9a-fA-F]{64}$") - registrationToken: Optional[str] = None - - -class LoginIn(BaseModel): - email: str - authHash: str = Field(pattern="^[0-9a-fA-F]{64}$") - - -class VaultItemIn(BaseModel): - labelCt: str = Field(min_length=1, max_length=4096) - payloadCt: str = Field(min_length=1, max_length=131072) - pinned: bool = False - - -class HistoryIn(BaseModel): - op: str = Field(pattern="^(encrypt|decrypt)$") - previewCt: str = Field(max_length=8192) - - -class ChangePwIn(BaseModel): - currentAuthHash: str = Field(pattern="^[0-9a-fA-F]{64}$") - newAuthSalt: str = Field(pattern="^[0-9a-fA-F]{32}$") - newAuthHash: str = Field(pattern="^[0-9a-fA-F]{64}$") - rewrappedItems: list[dict] - - -class DeleteAccountIn(BaseModel): - authHash: str = Field(pattern="^[0-9a-fA-F]{64}$") - - -class BaseMessageIn(BaseModel): - ciphertext: str = Field(min_length=1, max_length=131072) - hint: Optional[str] = Field(default=None, max_length=120) - replyToId: Optional[int] = None - mediaIds: Optional[list[str]] = Field(default=None, max_items=20) - - -class MessageIn(BaseMessageIn): - recipientId: int - - -class GroupMessageIn(BaseMessageIn): - pass - - -class PushSubscriptionIn(BaseModel): - subscription: dict - - -class BlockIn(BaseModel): - userId: int - - -class ReportIn(BaseModel): - reportedUserId: Optional[int] = None - messageId: Optional[int] = None - groupMessageId: Optional[int] = None - reason: str = Field(min_length=5, max_length=500) - details: Optional[str] = Field(default=None, max_length=2000) - - -class InviteToGroupIn(BaseModel): - email: str = Field(min_length=3, max_length=254) - # The inviter must pre-wrap the group key for the invitee's public key - # (or derive it via the group passphrase on the client) before sending. - wrappedKeyForInvitee: str = Field(min_length=1, max_length=4096) - - -class AcceptInviteIn(BaseModel): - # The invitee may optionally re-wrap the key (e.g. to a different local - # key format); if omitted the server uses the key the inviter stored. - wrappedKey: Optional[str] = Field(default=None, min_length=1, max_length=4096) - - -app = FastAPI(title="ASCII Cipher Vault", openapi_url=None, docs_url=None, redoc_url=None) - - -@app.on_event("startup") -def _startup(): - init_db() - - -@app.middleware("http") -async def security_headers(request: Request, call_next): - response = await call_next(request) - response.headers["X-Content-Type-Options"] = "nosniff" - response.headers["X-Frame-Options"] = "DENY" - response.headers["Referrer-Policy"] = "no-referrer" - response.headers["Cross-Origin-Opener-Policy"] = "same-origin" - response.headers["Permissions-Policy"] = "interest-cohort=(), browsing-topics=()" - if not request.url.path.startswith("/static/"): - response.headers["Cache-Control"] = "no-store" - response.headers["Content-Security-Policy"] = ( - "default-src 'self'; " - "script-src 'self'; " - "style-src 'self' 'unsafe-inline'; " - "img-src 'self' data: blob:; " - "media-src 'self' blob:; " - "connect-src 'self'; " - "frame-ancestors 'none'; " - "base-uri 'self'; " - "form-action 'self'" - ) - return response - - -@app.post("/api/auth/preflight") -def preflight(body: PreflightIn, request: Request): - rate_limit(f"preflight:{client_ip(request)}", 30, 60) - email = body.email.lower().strip() - if not EMAIL_RE.match(email): - return {"authSalt": deterministic_salt(email), "iterations": PBKDF2_ITERATIONS} - with db() as conn: - row = conn.execute("SELECT auth_salt FROM users WHERE email = ?", (email,)).fetchone() - salt_hex = row["auth_salt"].hex() if row else deterministic_salt(email) - return {"authSalt": salt_hex, "iterations": PBKDF2_ITERATIONS} - - -@app.post("/api/auth/register", status_code=201) -def register(body: RegisterIn, request: Request, response: Response): - rate_limit(f"register:{client_ip(request)}", 10, 3600) - if REGISTRATION_TOKEN: - if not body.registrationToken or not secrets.compare_digest(body.registrationToken, REGISTRATION_TOKEN): - raise HTTPException(403, "Invalid registration token") - email = body.email.lower().strip() - if not EMAIL_RE.match(email): - raise HTTPException(400, "Invalid email") - salt_bytes = bytes.fromhex(body.authSalt) - auth_hash_stored = hasher.hash(body.authHash.lower()) - now = int(time.time()) - with db() as conn: - try: - cur = conn.execute( - "INSERT INTO users (email, auth_salt, auth_hash, created_at, last_login_at) VALUES (?,?,?,?,?)", - (email, salt_bytes, auth_hash_stored, now, now) - ) - except sqlite3.IntegrityError: - raise HTTPException(409, "Email already registered") - user_id = cur.lastrowid - make_session(user_id, request, response) - return {"id": user_id, "email": email, "createdAt": now} - - -@app.post("/api/auth/login") -def login(body: LoginIn, request: Request, response: Response): - rate_limit(f"login:{client_ip(request)}", 10, 60) - email = body.email.lower().strip() - with db() as conn: - row = conn.execute("SELECT id, auth_hash FROM users WHERE email = ?", (email,)).fetchone() - if not row: - raise HTTPException(401, "Invalid credentials") - try: - hasher.verify(row["auth_hash"], body.authHash.lower()) - except (VerifyMismatchError, InvalidHash): - raise HTTPException(401, "Invalid credentials") - make_session(row["id"], request, response) - _log_login(row["id"], request) - with db() as conn: - conn.execute("UPDATE users SET last_login_at = ? WHERE id = ?", (int(time.time()), row["id"])) - return {"id": row["id"], "email": email} - - -@app.post("/api/auth/verify") -def verify_password(body: LoginIn, user = Depends(auth_dep)): - if user["email"] != body.email.lower().strip(): - raise HTTPException(403, "Email mismatch") - try: - hasher.verify(user["auth_hash"], body.authHash.lower()) - except (VerifyMismatchError, InvalidHash): - raise HTTPException(401, "Wrong password") - return {"ok": True} - - -@app.post("/api/auth/logout") -def logout(request: Request, response: Response): - sid = request.cookies.get(SESSION_COOKIE) - if sid: - with db() as conn: - conn.execute("DELETE FROM sessions WHERE id = ?", (sid,)) - response.delete_cookie(SESSION_COOKIE, path="/") - response.delete_cookie(CSRF_COOKIE, path="/") - return Response(status_code=204) - - -@app.get("/api/auth/me") -def me(user = Depends(require_user)): - return { - "id": user["id"], - "email": user["email"], - "authSalt": user["auth_salt"].hex(), - "createdAt": user["created_at"], - "lastLoginAt": user["last_login_at"], - "iterations": PBKDF2_ITERATIONS, + + + + + +SecureVault — Encrypted Messaging & Storage + + + + +
+
+

SecureVault

+

Encrypted messaging & storage

+
+
Sign In
+
Register
+
+
+ + + + +
+
+
+ + + +
+ + + + From 68b9c69dcffaaeb3815e76e6b80dddc5a005fb4c Mon Sep 17 00:00:00 2001 From: Syme-6005 Date: Wed, 13 May 2026 10:30:18 +0000 Subject: [PATCH 03/42] fix: restore backend/main.py and define LookupIn model for message lookup --- backend/main.py | 2871 +++++++++++++++++++++++++---------------------- 1 file changed, 1542 insertions(+), 1329 deletions(-) diff --git a/backend/main.py b/backend/main.py index f0bd45d..3dd44ef 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,1334 +1,1547 @@ - - - - - -SecureVault — Encrypted Messaging & Storage - - - - -
-
-

SecureVault

-

Encrypted messaging & storage

-
-
Sign In
-
Register
-
-
- - - - -
-
-
- - - -
- - - - + + +@app.get("/api/invites") +def list_pending_invites(user = Depends(require_user)): + """Return all pending (unaccepted, unexpired) invites addressed to the current user.""" + now = int(time.time()) + with db() as conn: + rows = conn.execute( + """SELECT gi.token, gi.group_id, g.name AS group_name, + u.email AS inviter_email, gi.expires_at + FROM group_invites gi + JOIN groups g ON g.id = gi.group_id + JOIN users u ON u.id = gi.inviter_id + WHERE gi.invitee_id = ? + AND gi.accepted_at IS NULL + AND gi.expires_at > ? + ORDER BY gi.created_at DESC""", + (user["id"], now) + ).fetchall() + return [{ + "token": r["token"], + "groupId": r["group_id"], + "groupName": r["group_name"], + "inviterEmail": r["inviter_email"], + "expiresAt": r["expires_at"], + } for r in rows] + + +# ── Blocking ────────────────────────────────────────────────────────────────── + +@app.post("/api/block", status_code=201) +def block_user(body: BlockIn, user = Depends(auth_dep)): + if body.userId == user["id"]: + raise HTTPException(400, "Cannot block yourself") + now = int(time.time()) + with db() as conn: + target = conn.execute("SELECT id FROM users WHERE id = ?", (body.userId,)).fetchone() + if not target: + raise HTTPException(404, "User not found") + try: + conn.execute( + "INSERT INTO blocked_users (blocker_id, blocked_id, created_at) VALUES (?,?,?)", + (user["id"], body.userId, now) + ) + except sqlite3.IntegrityError: + raise HTTPException(400, "Already blocked") + return {"ok": True} + + +@app.delete("/api/block/{user_id}") +def unblock_user(user_id: int, user = Depends(auth_dep)): + with db() as conn: + conn.execute( + "DELETE FROM blocked_users WHERE blocker_id = ? AND blocked_id = ?", + (user["id"], user_id) + ) + return Response(status_code=204) + + +@app.get("/api/blocks") +def list_blocked(user = Depends(auth_dep)): + with db() as conn: + rows = conn.execute( + "SELECT u.id, u.email, b.created_at FROM blocked_users b " + "JOIN users u ON b.blocked_id = u.id WHERE b.blocker_id = ? " + "ORDER BY b.created_at DESC", + (user["id"],) + ).fetchall() + return [{"id": r["id"], "email": r["email"], "blockedAt": r["created_at"]} for r in rows] + + +# ── Reports ─────────────────────────────────────────────────────────────────── + +@app.post("/api/report", status_code=201) +def report(body: ReportIn, user = Depends(auth_dep)): + if not body.reportedUserId and not body.messageId and not body.groupMessageId: + raise HTTPException(400, "Must report a user or message") + now = int(time.time()) + with db() as conn: + cur = conn.execute( + "INSERT INTO reports (reporter_id, reported_user_id, message_id, group_message_id, reason, details, created_at) " + "VALUES (?,?,?,?,?,?,?)", + (user["id"], body.reportedUserId, body.messageId, body.groupMessageId, body.reason, body.details, now) + ) + return {"id": cur.lastrowid} + + +# ── Session management ──────────────────────────────────────────────────────── + +@app.post("/api/auth/logout-all") +def logout_all(body: DeleteAccountIn, user = Depends(auth_dep)): + """Logout all other sessions, require password (auth_hash) verification""" + try: + hasher.verify(user["auth_hash"], body.authHash.lower()) + except (VerifyMismatchError, InvalidHash): + raise HTTPException(401, "Wrong password") + with db() as conn: + conn.execute( + "DELETE FROM sessions WHERE user_id = ? AND id != ?", + (user["id"], user["sess_id"]) + ) + return {"ok": True} + + +# ── Login tracking ──────────────────────────────────────────────────────────── + +def _log_login(user_id: int, request: Request): + """Track login history for sign-in notifications""" + try: + ip = client_ip(request) + ua = request.headers.get("user-agent", "unknown")[:500] + now = int(time.time()) + with db() as conn: + conn.execute( + "INSERT INTO login_history (user_id, ip, user_agent, created_at) VALUES (?,?,?,?)", + (user_id, ip, ua, now) + ) + except Exception as e: + print(f"[login_history] error: {e}") + + +@app.get("/api/auth/logins") +def get_logins(user = Depends(require_user)): + """List recent logins""" + with db() as conn: + rows = conn.execute( + "SELECT ip, user_agent, created_at FROM login_history WHERE user_id = ? " + "ORDER BY created_at DESC LIMIT 50", + (user["id"],) + ).fetchall() + return [{ + "ip": r["ip"], + "userAgent": r["user_agent"], + "createdAt": r["created_at"] + } for r in rows] + + +# ── Media uploads (zero-knowledge: ciphertext only) ─────────────────────────── + +@app.post("/api/media", status_code=201) +async def media_upload(request: Request, user = Depends(auth_dep)): + """Accept encrypted media blob and store it. Returns media_id for the recipient.""" + content_length = int(request.headers.get("content-length", 0)) + if content_length > 50 * 1024 * 1024: # 50MB max + raise HTTPException(413, "File too large (max 50MB)") + body = await request.body() + if len(body) == 0: + raise HTTPException(400, "Empty body") + media_dir = DB_PATH.parent / "media" + media_dir.mkdir(parents=True, exist_ok=True) + media_id = secrets.token_urlsafe(24) + now = int(time.time()) + with db() as conn: + conn.execute( + "INSERT INTO media (id, user_id, size_bytes, created_at) VALUES (?,?,?,?)", + (media_id, user["id"], len(body), now) + ) + # The uploader always has access to their own file. + conn.execute( + "INSERT OR IGNORE INTO media_access (media_id, user_id, granted_at) VALUES (?,?,?)", + (media_id, user["id"], now) + ) + (media_dir / media_id).write_bytes(body) + return {"mediaId": media_id, "size": len(body)} + + +@app.get("/api/media/{media_id}") +def media_get(media_id: str, user = Depends(require_user)): + """ + Return the raw encrypted blob. + + Access is restricted to: + - the original uploader, and + - any user explicitly granted access (i.e. a message recipient or group + member whose message included this media_id). + """ + if not re.match(r"^[A-Za-z0-9_\-]{1,64}$", media_id): + raise HTTPException(400, "Invalid media id") + with db() as conn: + access = conn.execute( + "SELECT 1 FROM media_access WHERE media_id = ? AND user_id = ?", + (media_id, user["id"]) + ).fetchone() + if not access: + raise HTTPException(403, "Access denied") + media_dir = DB_PATH.parent / "media" + path = media_dir / media_id + if not path.is_file(): + raise HTTPException(404, "Not found") + return FileResponse(path, media_type="application/octet-stream", + headers={"Cache-Control": "no-store"}) + + +@app.get("/api/health") +def health(): + return {"ok": True, "time": int(time.time())} + + +if STATIC_DIR.exists(): + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + @app.get("/") + def index_root(): + return FileResponse(STATIC_DIR / "index.html", headers={"Cache-Control": "no-store"}) + + @app.get("/standalone") + def standalone(): + f = STATIC_DIR / "cipher.html" + if not f.is_file(): + raise HTTPException(404) + return FileResponse(f) From 8b6c5c91c3ac781c7b4919725d0b01faa968fd8d Mon Sep 17 00:00:00 2001 From: Syme-6005 Date: Wed, 13 May 2026 10:34:18 +0000 Subject: [PATCH 04/42] fix: define GroupCreateIn and GroupJoinIn models for group endpoints --- backend/main.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/backend/main.py b/backend/main.py index 3dd44ef..7766f46 100644 --- a/backend/main.py +++ b/backend/main.py @@ -417,6 +417,18 @@ class GroupMessageIn(BaseMessageIn): pass +class GroupCreateIn(BaseModel): + name: str = Field(min_length=1, max_length=255) + salt: str = Field(pattern="^[0-9a-fA-F]{32}$") + authHash: str = Field(pattern="^[0-9a-fA-F]{64}$") + wrappedKey: str = Field(min_length=1, max_length=4096) + + +class GroupJoinIn(BaseModel): + authHash: str = Field(pattern="^[0-9a-fA-F]{64}$") + wrappedKey: str = Field(min_length=1, max_length=4096) + + class PushSubscriptionIn(BaseModel): subscription: dict From 39c8a405fc44181e8b16e484142e38bc0f2b06cc Mon Sep 17 00:00:00 2001 From: Syme-6005 Date: Wed, 13 May 2026 10:37:38 +0000 Subject: [PATCH 05/42] fix: update GitHub labeler rules for repo structure --- .github/labeler.yml | 34 +++++++++++----------------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/.github/labeler.yml b/.github/labeler.yml index 049dc96..a107561 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,41 +1,29 @@ # Area labels — applied based on which files changed in the PR. # Type labels (bug, enhancement, etc.) are applied separately from the PR title prefix. -"area: frontend": - - changed-files: - - any-glob-to-any-file: - - "src/cli/**" - -"area: api": +"area: backend": - changed-files: - any-glob-to-any-file: - - "src/**" - - "include/**" + - "backend/**" -"area: auth": - - changed-files: - - any-glob-to-any-file: - - "src/crypto/**" - - "include/crypto/**" - -"area: database": +"area: frontend": - changed-files: - any-glob-to-any-file: - - "src/utils/**" - - "include/utils/**" + - "static/**" + - "cipher.html" + - "encryption python.py" "area: docs": - changed-files: - any-glob-to-any-file: - - "docs/**" - "*.md" + - "README.md" "area: infra": - changed-files: - any-glob-to-any-file: - ".github/**" - - "CMakeLists.txt" - - "cmake/**" - - "scripts/**" - - ".env.example" - - "*.config.*" + - "Dockerfile" + - "docker-compose.yml" + - "deploy.sh" + - "requirements.txt" From 17e4dd9dad676b930c6520d7d4317a563d3dede3 Mon Sep 17 00:00:00 2001 From: Syme-6005 Date: Wed, 13 May 2026 10:44:04 +0000 Subject: [PATCH 06/42] fix: remove unnecessary 'hidden' class from panel elements in cipher.html --- cipher.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cipher.html b/cipher.html index 5ba7b3a..5c002fd 100644 --- a/cipher.html +++ b/cipher.html @@ -375,7 +375,7 @@ } /* ── Util ── */ - .hidden { display: none !important; } + .hidden { display: none; } .loading { opacity: 0.5; pointer-events: none; } .spin { animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } @@ -560,7 +560,7 @@

ASCII Cipher Vault

- + + + +