diff --git a/VERSION b/VERSION index 655b900..328047e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.52.0 \ No newline at end of file +0.53.0 \ No newline at end of file diff --git a/backend/alembic/versions/0059_dl_link_pw_version.py b/backend/alembic/versions/0059_dl_link_pw_version.py new file mode 100644 index 0000000..fdd764b --- /dev/null +++ b/backend/alembic/versions/0059_dl_link_pw_version.py @@ -0,0 +1,32 @@ +"""add password_version to download_links (invalidate watch grants on password change) + +Revision ID: 0059_dl_link_pw_version +Revises: 0058_dl_default_720p +Create Date: 2026-07-23 + +A password-protected share link mints a short-lived HMAC "grant" at /unlock so the media URL never +carries the password. The grant previously signed only ., so rotating a link's password +did NOT invalidate outstanding grants — they lived out their 6h TTL. Fold a per-link password_version +into the signed grant and bump it on every password change, so a rotation revokes old grants at once. +Small table (one row per share link), so this is a quick add-column with a default. +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0059_dl_link_pw_version" +down_revision: Union[str, None] = "0058_dl_default_720p" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "download_links", + sa.Column("password_version", sa.Integer(), nullable=False, server_default="0"), + ) + + +def downgrade() -> None: + op.drop_column("download_links", "password_version") diff --git a/backend/app/auth.py b/backend/app/auth.py index cb5b83e..024f62a 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -9,6 +9,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request from fastapi.responses import JSONResponse, RedirectResponse from starlette.requests import HTTPConnection from sqlalchemy import delete, func, select, update +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app import email as email_mod @@ -31,6 +32,7 @@ _DECOY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16)) _register_limiter = RateLimiter(max_events=5, window_seconds=300) _login_limiter = RateLimiter(max_events=10, window_seconds=300) _reset_limiter = RateLimiter(max_events=5, window_seconds=300) +_verify_resend_limiter = RateLimiter(max_events=5, window_seconds=300) _request_access_limiter = RateLimiter(max_events=5, window_seconds=300) # At most one "your account is suspended" email per address per hour, so a suspended user who # keeps retrying (or a script with their valid credentials) can't be used to mailbomb them. @@ -245,6 +247,34 @@ def upsert_pending_invite(db: Session, email: str) -> Invite | None: return inv +# Local-dev origins allowed to drive the OAuth redirect_uri from the request Host. The Vite dev +# server (:5173) proxies /auth to the backend (:8080) preserving the Host header, and the session +# cookie is port-agnostic — so a flow started on :5173 can come back to :5173 instead of always +# landing on the fixed :8080 callback. Deriving the redirect_uri from the request host keeps the +# WHOLE round-trip on ONE origin (login, Google callback, and the session/state cookie all on the +# same host) — that consistency is load-bearing, because a session cookie set on 127.0.0.1 is NOT +# sent to localhost and vice versa, so a cross-host fallback would silently lose the OAuth state. +# EVERY host listed here must ALSO be registered as a redirect URI in the Google console, or Google +# rejects it with redirect_uri_mismatch. An unlisted host falls back to the fixed configured +# callback (fine only when that lands on the same host the flow started on). +_DEV_OAUTH_HOSTS = frozenset( + {"localhost:5173", "localhost:8080", "127.0.0.1:5173", "127.0.0.1:8080"} +) + + +def _oauth_redirect_uri(request: Request) -> str: + """The redirect_uri handed to Google. Production always uses the fixed configured URL and never + trusts the request Host (a forged Host header must not be able to redirect the OAuth code). In + local dev we honor the request's own origin when it is one of the known dev hosts, so a login + started on the Vite server (:5173) returns to :5173 and one on :8080 returns to :8080.""" + if settings.session_https_only: # https redirect URL == real deployment + return settings.oauth_redirect_url + host = (request.headers.get("host") or "").lower() + if host in _DEV_OAUTH_HOSTS: + return f"http://{host}/auth/callback" + return settings.oauth_redirect_url + + @router.get("/login") async def login(request: Request, db: Session = Depends(get_db)): # access_type=offline ensures a refresh_token on first authorization. We avoid @@ -257,7 +287,7 @@ async def login(request: Request, db: Session = Depends(get_db)): return RedirectResponse(url="/") return await client.authorize_redirect( request, - settings.oauth_redirect_url, + _oauth_redirect_uri(request), access_type="offline", prompt="select_account", include_granted_scopes="true", @@ -274,10 +304,27 @@ async def callback( client = google_oauth(db) if client is None: return RedirectResponse(url="/") + # Linking mode: an already-authenticated account is attaching this Google identity (a + # password account enabling YouTube or connecting SSO). The markers are set by /auth/link and + # /auth/upgrade; when present we attach to the current user instead of identifying by sub. + # Pop them BEFORE the token exchange: if the user aborts Google's consent screen the exchange + # throws below, and a marker left behind would silently turn the NEXT clean sign-in into a link + # attempt. Popping up front means an aborted link flow leaves no residue in the session. + link_uid = request.session.pop("oauth_link_uid", None) + link_explicit = request.session.pop("oauth_link_explicit", False) try: token = await client.authorize_access_token(request) except OAuthError as exc: - raise HTTPException(status_code=400, detail=f"OAuth error: {exc.error}") + # The user cancelled or denied consent at Google — land back with a friendly notice instead + # of a raw 400 JSON error page. An already-signed-in user (adding another account via + # /auth/login, or linking/upgrading via /auth/link|upgrade) never sees the pre-auth login + # banner, so surface a neutral toast via ?oauth=cancelled; a fresh sign-in cancel lands on + # the login page with the oauth_cancelled banner instead. Keyed on the live session, not on + # link_uid, so the add-account path (no marker) is covered too. + log.info("Google OAuth cancelled/failed: %s", exc.error) + if request.session.get("user_id") is not None: + return RedirectResponse(url="/?oauth=cancelled") + return RedirectResponse(url="/?login=oauth_cancelled") userinfo = token.get("userinfo") if not userinfo or not userinfo.get("sub"): @@ -286,12 +333,8 @@ async def callback( sub = userinfo["sub"] email = (userinfo.get("email") or "").lower() - # Linking mode: an already-authenticated account is attaching this Google identity (a - # password account enabling YouTube or connecting SSO). The marker is set by /auth/link and - # /auth/upgrade; when present we attach to the current user instead of identifying by sub. - link_uid = request.session.pop("oauth_link_uid", None) if link_uid is not None: - return _complete_link(request, db, link_uid, sub, userinfo, token) + return _complete_link(request, db, link_uid, link_explicit, sub, userinfo, token) if not is_allowed(db, email): log.warning("Login denied (not approved): %s", email or "") @@ -456,13 +499,19 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None: def _complete_link( - request: Request, db: Session, link_uid: int, sub: str, userinfo: dict, token: dict + request: Request, + db: Session, + link_uid: int, + explicit: bool, + sub: str, + userinfo: dict, + token: dict, ): """Attach the just-authorized Google identity to the already-signed-in account that started the link/upgrade. No is_allowed gate — they're already an active user. Refuses to hijack a - Google identity owned by another account, or to silently swap a different linked identity.""" - # Consume the companion marker up front so it never lingers across the early returns below. - explicit = request.session.pop("oauth_link_explicit", False) + Google identity owned by another account, or to silently swap a different linked identity. + `explicit` (the popped oauth_link_explicit marker) distinguishes a "Connect Google" click from + a YouTube upgrade — the caller pops it up front so an aborted flow leaves nothing behind.""" # The marker must match the live session user (guards a stale/forged marker). if link_uid != request.session.get("user_id"): return RedirectResponse(url="/?link=error") @@ -670,7 +719,14 @@ def _register_account(email: str, password: str) -> None: email_verified=not email_ok, ) db.add(user) - db.flush() + try: + db.flush() + except IntegrityError: + # A concurrent registration for the same new email won the race between the pre-check + # above and this flush (the unique-email constraint fires). Same already-registered + # no-op as the pre-check hit — roll back and stop. + db.rollback() + return upsert_pending_invite(db, email) # admin-approval gate (commits) if email_ok: raw = _issue_token(db, user, "verify", VERIFY_TTL) @@ -693,6 +749,39 @@ def verify_email_confirm(payload: dict, db: Session = Depends(get_db)) -> dict: return {"ok": True} +@router.post("/verify/resend") +def verify_email_resend( + payload: dict, + request: Request, + background: BackgroundTasks, +) -> dict: + """Re-issue an email-verification link. Needed because the original link expires (VERIFY_TTL) + and re-registering an already-registered email is a silent no-op, so a user who lost the first + mail had no way back. Uniform response + timing regardless of whether the email has a pending + account (the lookup runs off the response path), so it can't probe for registered emails.""" + if not _verify_resend_limiter.allow(_client_ip(request)): + return {"status": "ok"} # silently throttle; uniform response + email = (payload.get("email") or "").strip().lower() + if valid_email(email): + background.add_task(_resend_verify_if_eligible, email) + return {"status": "ok"} + + +def _resend_verify_if_eligible(email: str) -> None: + """Background worker for verify/resend: issue a fresh verify token + email the link, but ONLY for + a real account that still needs verifying. Runs after the response with its own DB session, so the + request's timing never reveals whether the account exists. No-op when SMTP is disabled (accounts + are auto-verified on a no-SMTP instance, so there is nothing to resend). Token rides the URL + FRAGMENT (#) so it can't leak into proxy/access logs or a Referer (SB3).""" + if not email_mod.email_enabled(): + return + with SessionLocal() as db: + user = db.execute(select(User).where(User.email == email)).scalar_one_or_none() + if user is not None and not user.email_verified and not user.is_demo: + raw = _issue_token(db, user, "verify", VERIFY_TTL) + email_mod.send_verify_email(email, f"{_app_base()}/#verify={raw}") + + @router.get("/verify") def verify_email(token: str, db: Session = Depends(get_db)): """Legacy query-token verification link (pre-SB3 emails still in flight). New emails use the @@ -787,6 +876,11 @@ def password_reset_confirm(payload: dict, db: Session = Depends(get_db)) -> dict if user is None: raise HTTPException(status_code=400, detail="This reset link is invalid or has expired.") user.password_hash = hash_password(new_password) + # A valid reset link proves control of the mailbox, so completing a reset also verifies the + # email — this rescues an account that registered but never clicked the verification link (it + # can then sign in). The separate admin-approval gate (is_active) is deliberately NOT touched + # here: a reset proves email ownership, not that an admin approved the account. + user.email_verified = True bump_session_epoch(user) # SA4: a reset (often a compromise response) kills all existing sessions. # Burn any other outstanding reset tokens for this user. for row in db.execute( @@ -951,7 +1045,7 @@ async def link_google( request.session["oauth_link_explicit"] = True return await client.authorize_redirect( request, - settings.oauth_redirect_url, + _oauth_redirect_uri(request), access_type="offline", prompt="select_account", include_granted_scopes="true", @@ -1016,7 +1110,7 @@ async def upgrade( scope = WRITE_SCOPES if access == "write" else READ_SCOPES return await client.authorize_redirect( request, - settings.oauth_redirect_url, + _oauth_redirect_uri(request), access_type="offline", prompt="consent", include_granted_scopes="true", diff --git a/backend/app/downloads/links.py b/backend/app/downloads/links.py index 6b18cbd..70aad0e 100644 --- a/backend/app/downloads/links.py +++ b/backend/app/downloads/links.py @@ -33,13 +33,17 @@ def _sign(msg: str) -> str: ).hexdigest()[:32] -def make_grant(token: str, now: float | None = None) -> str: - """A short-lived HMAC grant proving the viewer unlocked `token`. Format: `.`.""" +def make_grant(token: str, password_version: int, now: float | None = None) -> str: + """A short-lived HMAC grant proving the viewer unlocked `token`. Format: `.`. The + link's current `password_version` is folded into the signature, so rotating the password (which + bumps the version) invalidates every grant minted under the old password at once.""" exp = int((now if now is not None else datetime.now(timezone.utc).timestamp())) + _GRANT_TTL - return f"{exp}.{_sign(f'{token}.{exp}')}" + return f"{exp}.{_sign(f'{token}.{exp}.{password_version}')}" -def check_grant(token: str, grant: str | None) -> bool: +def check_grant(token: str, grant: str | None, password_version: int) -> bool: + """Verify a grant against the link's CURRENT `password_version`; a version bump (password + rotation) fails the signature check even before the grant's TTL expires.""" if not grant or "." not in grant: return False exp_s, sig = grant.split(".", 1) @@ -49,7 +53,7 @@ def check_grant(token: str, grant: str | None) -> bool: return False if exp < datetime.now(timezone.utc).timestamp(): return False - return hmac.compare_digest(sig, _sign(f"{token}.{exp}")) + return hmac.compare_digest(sig, _sign(f"{token}.{exp}.{password_version}")) # --- serialization ------------------------------------------------------------------------- diff --git a/backend/app/models.py b/backend/app/models.py index 3e67cfd..c477979 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -943,6 +943,10 @@ class DownloadLink(Base, TimestampMixin): Boolean, default=False, server_default="false" ) password_hash: Mapped[str | None] = mapped_column(Text) + # Bumped whenever the password changes; folded into the signed watch grant so rotating the + # password immediately invalidates any grants issued under the old one (instead of them living + # out the 6h grant TTL). + password_version: Mapped[int] = mapped_column(Integer, default=0, server_default="0") expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) view_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0") diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 39a36e0..f3b4943 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -31,6 +31,7 @@ from app.models import ( User, ) from app.security import hash_password +from app.userscope import is_messageable_user, messageable_clauses router = APIRouter(prefix="/api/downloads", tags=["downloads"]) admin_router = APIRouter(prefix="/api/admin/downloads", tags=["admin-downloads"]) @@ -666,7 +667,9 @@ def share_download( recipient = db.execute( select(User).where(func.lower(User.email) == target) ).scalar_one_or_none() - if recipient is None: + # Uniform "no user" for a missing OR non-messageable (demo/suspended/deactivated) target, so + # sharing can't be used to probe which addresses exist or to reach a suspended account. + if not is_messageable_user(recipient): raise HTTPException(status_code=404, detail="No user with that email.") if recipient.id == user.id: raise HTTPException(status_code=400, detail="That's already your download.") @@ -741,11 +744,12 @@ def remove_shared_with_me( def share_recipients( user: User = Depends(require_human), db: Session = Depends(get_db) ) -> list[dict]: - """Registered users this user can share a download with (excludes self + the demo account).""" + """Registered users this user can share a download with (excludes self + any non-messageable + account — demo, suspended, or deactivated — so the picker never leaks those addresses).""" rows = ( db.execute( select(User) - .where(User.id != user.id, User.is_demo.is_(False)) + .where(User.id != user.id, *messageable_clauses()) .order_by(func.lower(User.email)) ) .scalars() @@ -834,6 +838,9 @@ def update_link( if "password" in payload: pw = (payload.get("password") or "").strip() link.password_hash = hash_password(pw) if pw else None + # Bump the version so any grant issued under the previous password stops working now, rather + # than living out its 6h TTL (setting/clearing/changing the password all count as a rotation). + link.password_version += 1 db.commit() return linksmod.owner_view(link) diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index d40d3e7..7ac772e 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -17,7 +17,7 @@ from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect from pydantic import BaseModel -from sqlalchemy import and_, case, func, or_, select +from sqlalchemy import and_, case, delete, func, or_, select from sqlalchemy.orm import Session from app.auth import require_human, resolved_user_id @@ -25,6 +25,8 @@ from app.db import SessionLocal, get_db from app.models import Message, MessageKey, User from app.ratelimit import RateLimiter from app.realtime import manager +from app.security import verify_password +from app.userscope import is_messageable_user, messageable_clauses router = APIRouter(prefix="/api/messages", tags=["messages"]) @@ -32,6 +34,7 @@ MAX_CIPHERTEXT = 8000 # base64 of a generous plaintext + AES-GCM overhead SYSTEM_PARTNER_ID = 0 # synthetic conversation partner for system messages SYSTEM_NAME = "Siftlode" _send_limiter = RateLimiter(max_events=30, window_seconds=60) +_reset_key_limiter = RateLimiter(max_events=5, window_seconds=300) # The welcome is rendered server-side at creation time, so it carries its own translations # (one per supported UI language). Kept short and non-private (every new user gets the same text). @@ -62,6 +65,11 @@ class KeySetupIn(BaseModel): key_check: str +class KeyResetIn(BaseModel): + # Present only for password accounts; a Google-only account has no password to re-verify. + current_password: str | None = None + + class SendIn(BaseModel): recipient_id: int ciphertext: str @@ -94,18 +102,6 @@ def _serialize_msg(m: Message) -> dict: } -def _messageable() -> list: - """SQL clauses for "a real, active, non-suspended human" — for directory/recipient queries.""" - return [ - User.is_demo.is_(False), - User.is_active.is_(True), - User.is_suspended.is_(False), - ] - - -def is_messageable_user(u: User | None) -> bool: - """Python mirror of `_messageable()` for an already-loaded row (WS auth, send recipient).""" - return bool(u and not u.is_demo and u.is_active and not u.is_suspended) def ensure_welcome(db: Session, user: User) -> None: @@ -172,11 +168,63 @@ def setup_keys( return {"configured": True} +@router.post("/keys/reset") +def reset_keys( + payload: KeyResetIn, + user: User = Depends(require_human), + db: Session = Depends(get_db), +) -> dict: + """Forgotten-passphrase escape hatch: drop this user's secure-messaging key so they can set a + NEW passphrase (the set-once guard in setup_keys otherwise makes a lost passphrase a permanent + wall). Destroys the user's entire E2EE history BY DESIGN — every conversation key is derived + from this keypair, so replacing it orphans every stored ciphertext in both directions. Those + now-undecryptable `user` messages are deleted here (a clean slate for both parties) rather than + left as permanent "can't decrypt" clutter; `system` messages (plaintext) are untouched. + + An account WITH a password must re-authenticate: a hijacked session must not be able to silently + reset messaging and then read the victim's future messages under a new passphrase. A password- + less (Google-only) account has nothing to re-verify, so it relies on its live session plus the + client-side danger confirmation.""" + if not _reset_key_limiter.allow(str(user.id)): + raise HTTPException(status_code=429, detail="Too many attempts. Try again shortly.") + if user.password_hash and not verify_password( + payload.current_password or "", user.password_hash + ): + raise HTTPException(status_code=403, detail="Current password is incorrect.") + # Delete the now-dead ciphertext first (both directions), then the wrapped keypair itself so a + # fresh setup is allowed. Order doesn't matter (no FK between them), but do it in one commit. + db.execute( + delete(Message).where( + Message.kind == "user", + or_(Message.sender_id == user.id, Message.recipient_id == user.id), + ) + ) + key = db.get(MessageKey, user.id) + if key is not None: + db.delete(key) + db.commit() + return {"configured": False} + + @router.get("/keys/{user_id}") def public_key( user_id: int, user: User = Depends(require_human), db: Session = Depends(get_db) ) -> dict: - """A member's public key, for deriving the pairwise conversation key.""" + """A member's public key, for deriving the pairwise conversation key. MB2 (like get_thread): + resolve the target only if they're messageable OR we already share a thread, else the same + "User not found" as a non-existent id — so this can't be probed to enumerate user_ids or to + fetch keys for suspended/deactivated/demo accounts.""" + cond = and_( + Message.kind == "user", + or_( + and_(Message.sender_id == user.id, Message.recipient_id == user_id), + and_(Message.sender_id == user_id, Message.recipient_id == user.id), + ), + ) + p = db.get(User, user_id) + has_history = db.scalar(select(Message.id).where(cond).limit(1)) + if p is None or (not is_messageable_user(p) and not has_history): + raise HTTPException(status_code=404, detail="User not found") k = db.get(MessageKey, user_id) if k is None: raise HTTPException(status_code=404, detail="That member hasn't set up messaging yet.") @@ -195,7 +243,7 @@ def list_recipients( db.execute( select(User) .join(MessageKey, MessageKey.user_id == User.id) - .where(User.id != user.id, *_messageable()) + .where(User.id != user.id, *messageable_clauses()) .order_by(func.lower(func.coalesce(User.display_name, User.email))) ) .scalars() diff --git a/backend/app/routes/public.py b/backend/app/routes/public.py index 7c5b9ea..e7c3d0c 100644 --- a/backend/app/routes/public.py +++ b/backend/app/routes/public.py @@ -97,7 +97,7 @@ def watch_unlock(token: str, payload: dict, db: Session = Depends(get_db)) -> di job, asset = _ready_target(db, link) link.view_count += 1 db.commit() - grant = linksmod.make_grant(token) + grant = linksmod.make_grant(token, link.password_version) return { "needs_password": False, **linksmod.public_meta( @@ -111,7 +111,7 @@ def watch_file(token: str, g: str | None = None, db: Session = Depends(get_db)): """Range-aware stream of the shared file. Inline by default (plays in the page); an `allow_download` link serves it as an attachment. Password links require a valid grant.""" link = _link_or_404(db, token) - if link.password_hash and not linksmod.check_grant(token, g): + if link.password_hash and not linksmod.check_grant(token, g, link.password_version): raise HTTPException(status_code=403, detail="This link needs a password.") asset = _ready_asset(db, link) path = storage.safe_abs_path(settings.download_root, asset.rel_path) @@ -129,7 +129,7 @@ def watch_poster(token: str, g: str | None = None, db: Session = Depends(get_db) """The generated poster frame for a shared thumbnail-less video (used as the page/video poster and the link-preview image). Password links require a valid grant, like the file.""" link = _link_or_404(db, token) - if link.password_hash and not linksmod.check_grant(token, g): + if link.password_hash and not linksmod.check_grant(token, g, link.password_version): raise HTTPException(status_code=403, detail="This link needs a password.") job, asset = _ready_target(db, link) if not asset.poster_path: diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py index c08e985..2bf5a66 100644 --- a/backend/app/scheduler.py +++ b/backend/app/scheduler.py @@ -162,9 +162,15 @@ def _audit_run(db, actor_id, job_id, status, summary, result) -> None: def _job(name: str, fn) -> None: + # The one place both run paths converge (manual trigger AND scheduled fire), so claim the run + # HERE, atomically: whichever reaches this first wins, the other skips. This is the real + # single-run guard — trigger_job's pre-check is only a fast best-effort answer for the UI. + if not _claim_running(name): + logger.info("job %s already running — skipping this trigger", name) + return db = SessionLocal() actor_id = _manual_actor.get() # set only for a manual "run now" trigger - _record(name, running=True, last_started=_now_iso(), last_error=None, progress=None) + _record(name, last_started=_now_iso(), last_error=None, progress=None) # running already claimed def sink(current, total, phase): _record(name, progress={"current": current, "total": total, "phase": phase}) @@ -172,7 +178,7 @@ def _job(name: str, fn) -> None: try: if is_sync_paused(db): logger.info("job %s skipped (sync paused)", name) - _record(name, running=False, status="skipped", last_finished=_now_iso(), + _record(name, status="skipped", last_finished=_now_iso(), last_result="paused", progress=None) if actor_id is not None: _notify_done(db, actor_id, name, "skipped", "sync paused") @@ -182,7 +188,7 @@ def _job(name: str, fn) -> None: result = fn(db) summary = _summarize(result) logger.info("job %s -> %s", name, result) - _record(name, running=False, status="ok", last_finished=_now_iso(), + _record(name, status="ok", last_finished=_now_iso(), last_result=summary, progress=None) if actor_id is not None: _notify_done(db, actor_id, name, "ok", summary) @@ -191,12 +197,15 @@ def _job(name: str, fn) -> None: db.rollback() logger.exception("job %s failed", name) err = str(exc) or exc.__class__.__name__ - _record(name, running=False, status="error", last_finished=_now_iso(), + _record(name, status="error", last_finished=_now_iso(), last_error=err, progress=None) if actor_id is not None: _notify_done(db, actor_id, name, "error", err) _audit_run(db, actor_id, name, "error", err, None) finally: + # ALWAYS release the claim, even if the body raised before its own status update — so a + # failure can never wedge the job as permanently "running". + _record(name, running=False) db.close() @@ -380,6 +389,18 @@ def _is_running(job_id: str) -> bool: return bool(_activity.get(job_id, {}).get("running")) +def _claim_running(job_id: str) -> bool: + """Atomically claim a job as running: True if it was idle (and marks it running now), False if + it was already running. Holding the lock across BOTH the check and the set is what makes the + single-run guard race-free — called once at the top of _job so a manual trigger and a scheduled + fire (or two rapid triggers) converge here and exactly one wins. Released in _job's finally.""" + with _activity_lock: + if _activity.get(job_id, {}).get("running"): + return False + _activity.setdefault(job_id, {})["running"] = True + return True + + def running_job_ids() -> set[str]: """Ids of jobs executing right now (any trigger). Lets non-admin surfaces — e.g. the header's sync indicator — reflect real activity instead of just pending-work counts.""" @@ -399,6 +420,9 @@ def trigger_job(job_id: str, actor_id: int | None = None) -> str: fn = JOB_FUNCS.get(job_id) if fn is None: raise KeyError(job_id) + # Best-effort fast answer for the UI; the authoritative single-run guard is _job's atomic claim + # (so a manual trigger and a scheduled fire can't both run). A race here just means the loser's + # _job claims, sees it's taken, and skips — no double run. if _is_running(job_id): return "already_running" diff --git a/backend/app/userscope.py b/backend/app/userscope.py new file mode 100644 index 0000000..10d94c9 --- /dev/null +++ b/backend/app/userscope.py @@ -0,0 +1,22 @@ +"""Shared "who is a real, active, non-suspended human" predicate. + +Used wherever a user must be filtered out of a directory / recipient list or rejected as a target: +the messaging directory AND the download share-by-email / recipient-picker endpoints. Kept in one +place so the two forms (SQL clauses for a query, Python check for a loaded row) can't drift apart, +and so a route module doesn't have to import another route module just to reuse the rule. +""" +from app.models import User + + +def messageable_clauses() -> list: + """SQL clauses for "a real, active, non-suspended human" — splat into a directory/recipient query.""" + return [ + User.is_demo.is_(False), + User.is_active.is_(True), + User.is_suspended.is_(False), + ] + + +def is_messageable_user(u: User | None) -> bool: + """Python mirror of `messageable_clauses()` for an already-loaded row (WS auth, send recipient).""" + return bool(u and not u.is_demo and u.is_active and not u.is_suspended) diff --git a/backend/tests/test_links.py b/backend/tests/test_links.py index ef8a94b..296eff6 100644 --- a/backend/tests/test_links.py +++ b/backend/tests/test_links.py @@ -14,30 +14,37 @@ def test_new_token_is_unguessable_and_unique(): class TestGrant: def test_a_fresh_grant_verifies_for_its_own_token(self): - grant = make_grant("tok") - assert check_grant("tok", grant) is True + grant = make_grant("tok", 0) + assert check_grant("tok", grant, 0) is True def test_a_grant_does_not_verify_for_a_different_token(self): # The signature binds the token, so a grant minted for one link can't unlock another. - assert check_grant("other", make_grant("tok")) is False + assert check_grant("other", make_grant("tok", 0), 0) is False def test_a_tampered_signature_is_rejected(self): - exp, _sig = make_grant("tok").split(".", 1) - assert check_grant("tok", f"{exp}.deadbeef") is False + exp, _sig = make_grant("tok", 0).split(".", 1) + assert check_grant("tok", f"{exp}.deadbeef", 0) is False def test_a_tampered_expiry_is_rejected(self): - # Extending the exp field invalidates the signature (which covers token.exp). - _exp, sig = make_grant("tok").split(".", 1) + # Extending the exp field invalidates the signature (which covers token.exp.version). + _exp, sig = make_grant("tok", 0).split(".", 1) future = int(datetime.now(timezone.utc).timestamp()) + 10 * _GRANT_TTL - assert check_grant("tok", f"{future}.{sig}") is False + assert check_grant("tok", f"{future}.{sig}", 0) is False def test_an_expired_grant_is_rejected(self): past = datetime.now(timezone.utc).timestamp() - 2 * _GRANT_TTL - assert check_grant("tok", make_grant("tok", now=past)) is False + assert check_grant("tok", make_grant("tok", 0, now=past), 0) is False + + def test_a_password_rotation_invalidates_an_old_grant(self): + # A grant minted at version 0 must fail once the link's password_version has been bumped — + # this is what makes changing a link password revoke outstanding grants immediately. + grant = make_grant("tok", 0) + assert check_grant("tok", grant, 0) is True + assert check_grant("tok", grant, 1) is False def test_malformed_grants_are_rejected_not_crashed(self): for bad in [None, "", "no-dot", "notanumber.sig"]: - assert check_grant("tok", bad) is False + assert check_grant("tok", bad, 0) is False class TestIsExpired: diff --git a/backend/tests/test_oauth_redirect.py b/backend/tests/test_oauth_redirect.py new file mode 100644 index 0000000..eb418ce --- /dev/null +++ b/backend/tests/test_oauth_redirect.py @@ -0,0 +1,61 @@ +"""_oauth_redirect_uri decides where Google sends the OAuth code back. In production it must ALWAYS +use the fixed configured URL and never trust the request Host (a forged Host must not be able to +redirect the code); in local dev it honors a small allowlist of dev origins so a login started on the +Vite server (:5173) returns to :5173.""" +from types import SimpleNamespace + +from app import auth + + +def _req(host): + """Minimal stand-in for a Starlette Request: only .headers.get('host') is read.""" + return SimpleNamespace(headers={"host": host} if host is not None else {}) + + +def _dev_settings(): + return SimpleNamespace( + session_https_only=False, + oauth_redirect_url="http://localhost:8080/auth/callback", + ) + + +def _prod_settings(): + return SimpleNamespace( + session_https_only=True, + oauth_redirect_url="https://siftlode.example.eu/auth/callback", + ) + + +def test_dev_known_host_returns_to_that_origin(monkeypatch): + monkeypatch.setattr(auth, "settings", _dev_settings()) + # Each allowlisted host keeps the whole round-trip on one origin (all four are registered in the + # Google console), so the redirect_uri matches the host the flow started on. + assert auth._oauth_redirect_uri(_req("localhost:5173")) == "http://localhost:5173/auth/callback" + assert auth._oauth_redirect_uri(_req("localhost:8080")) == "http://localhost:8080/auth/callback" + assert auth._oauth_redirect_uri(_req("127.0.0.1:5173")) == "http://127.0.0.1:5173/auth/callback" + assert auth._oauth_redirect_uri(_req("127.0.0.1:8080")) == "http://127.0.0.1:8080/auth/callback" + + +def test_dev_host_is_case_insensitive(monkeypatch): + monkeypatch.setattr(auth, "settings", _dev_settings()) + assert auth._oauth_redirect_uri(_req("LOCALHOST:5173")) == "http://localhost:5173/auth/callback" + + +def test_dev_unknown_or_missing_host_falls_back_to_configured(monkeypatch): + monkeypatch.setattr(auth, "settings", _dev_settings()) + # An origin not on the allowlist (or a request with no Host) must not drive the redirect. + assert auth._oauth_redirect_uri(_req("evil.example.com")) == "http://localhost:8080/auth/callback" + assert auth._oauth_redirect_uri(_req(None)) == "http://localhost:8080/auth/callback" + + +def test_prod_ignores_host_even_when_it_matches_a_dev_origin(monkeypatch): + monkeypatch.setattr(auth, "settings", _prod_settings()) + # The host-injection guard: in production the fixed URL wins regardless of the Host header. + assert ( + auth._oauth_redirect_uri(_req("localhost:5173")) + == "https://siftlode.example.eu/auth/callback" + ) + assert ( + auth._oauth_redirect_uri(_req("attacker.test")) + == "https://siftlode.example.eu/auth/callback" + ) diff --git a/backend/tests/test_scheduler.py b/backend/tests/test_scheduler.py new file mode 100644 index 0000000..c047fec --- /dev/null +++ b/backend/tests/test_scheduler.py @@ -0,0 +1,24 @@ +"""_claim_running is the single-run guard that both the scheduled fire and the manual trigger go +through (at the top of _job): holding _activity_lock across the check-and-set means exactly one +caller can hold a job's "running" claim at a time, and it's claimable again once released.""" +from app import scheduler + +_JOB = "unit_test_claim" # not a real job id — exercises the primitive without touching live jobs + + +def _release(): + scheduler._record(_JOB, running=False) + + +def test_claim_is_exclusive_until_released(): + _release() + # First caller claims it. + assert scheduler._claim_running(_JOB) is True + # A second caller is refused while the first still holds it (the TOCTOU the fix closes). + assert scheduler._claim_running(_JOB) is False + assert scheduler._claim_running(_JOB) is False + # _job's finally releases the claim... + _release() + # ...and now it can be claimed again. + assert scheduler._claim_running(_JOB) is True + _release() diff --git a/backend/tests/test_userscope.py b/backend/tests/test_userscope.py new file mode 100644 index 0000000..8520edd --- /dev/null +++ b/backend/tests/test_userscope.py @@ -0,0 +1,23 @@ +"""is_messageable_user — the "real, active, non-suspended human" predicate reused by the messaging +directory and the download share endpoints. A row must be non-demo AND active AND not-suspended.""" +from types import SimpleNamespace + +from app.userscope import is_messageable_user + + +def _user(is_demo=False, is_active=True, is_suspended=False): + return SimpleNamespace(is_demo=is_demo, is_active=is_active, is_suspended=is_suspended) + + +def test_a_real_active_human_is_messageable(): + assert is_messageable_user(_user()) is True + + +def test_none_is_not_messageable(): + assert is_messageable_user(None) is False + + +def test_demo_suspended_or_inactive_are_not_messageable(): + assert is_messageable_user(_user(is_demo=True)) is False + assert is_messageable_user(_user(is_suspended=True)) is False + assert is_messageable_user(_user(is_active=False)) is False diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 804c01d..934dac3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -199,6 +199,15 @@ export default function App() { stripUrlParams(); }, []); // eslint-disable-line react-hooks/exhaustive-deps + // A Google OAuth flow started while already signed in (add-another-account, link, or upgrade) was + // cancelled at Google's consent screen. The backend only emits this for a live session, so a + // neutral toast is the right feedback — the pre-auth login banner would never render here. + useEffect(() => { + if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return; + notify({ level: "info", message: t("settings.account.googleCancelled") }); + stripUrlParams(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to // the admin Users → Access requests view. Snapshot the param so the pre-login URL strip doesn't // drop it; act once `me` is known, and only for an admin (others just land on their default page). diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx index f0fdb29..1eb1a43 100644 --- a/frontend/src/components/ChatThread.tsx +++ b/frontend/src/components/ChatThread.tsx @@ -8,7 +8,7 @@ import { useScrollFade } from "../lib/useScrollFade"; import { LoadingState, StateMessage } from "./QueryState"; import { relativeTime } from "../lib/format"; import { notify } from "../lib/notifications"; -import { partnerPub, POLL_MS, useKeyState } from "../lib/messaging"; +import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging"; import * as e2ee from "../lib/e2ee"; import KeyGate from "./KeyGate"; @@ -49,14 +49,32 @@ export default function ChatThread({ let cancelled = false; (async () => { const out: Record = {}; + // Fetch the partner key lazily inside the try (only when there's a ciphertext message) and + // once — so an empty thread never fetches, and a fetch failure (e.g. a partner who reset and + // hasn't re-set-up, so has no key) degrades to "can't decrypt" instead of rejecting the pass. + let pub: string | null = null; + let refreshed = false; for (const m of q.data?.items ?? []) { - if (m.ciphertext && m.iv) { - try { - const pub = await partnerPub(partnerId); - out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv); - } catch { - out[m.id] = "⚠ " + t("messages.cantDecrypt"); + if (!m.ciphertext || !m.iv) continue; + try { + if (!pub) pub = await partnerPub(partnerId); + out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv); + } catch { + // A decrypt failure can mean the partner rotated their keypair (a passphrase reset), so + // this device's cached public key is stale. Refresh it ONCE per pass and retry — that + // also re-derives future sends under the current key. A genuinely bad message still ends + // up "can't decrypt". + if (!refreshed) { + refreshed = true; + try { + pub = await refreshPartnerPub(partnerId); + out[m.id] = await e2ee.decryptFrom(partnerId, pub, m.ciphertext, m.iv); + continue; + } catch { + /* fall through to the error label below */ + } } + out[m.id] = "⚠ " + t("messages.cantDecrypt"); } } if (!cancelled) setPlain(out); diff --git a/frontend/src/components/KeyGate.tsx b/frontend/src/components/KeyGate.tsx index d9d7722..caf6dcc 100644 --- a/frontend/src/components/KeyGate.tsx +++ b/frontend/src/components/KeyGate.tsx @@ -1,13 +1,17 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { Lock, ShieldCheck } from "lucide-react"; -import { api } from "../lib/api"; +import { AlertTriangle, Lock, ShieldCheck } from "lucide-react"; +import { api, HttpError } from "../lib/api"; import * as e2ee from "../lib/e2ee"; +const inputCls = + "w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent"; + // Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device. // Self-contained — on success it updates the shared e2ee unlock state, so any observing view -// (the Messages page, a dock window) swaps out of the gate automatically. +// (the Messages page, a dock window) swaps out of the gate automatically. In unlock mode it also +// offers a forgotten-passphrase reset (the only escape from the set-once wall). export default function KeyGate({ meId, compact = false }: { meId: number; compact?: boolean }) { const { t } = useTranslation(); const qc = useQueryClient(); @@ -17,9 +21,16 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa staleTime: 60_000, }); const configured = keyQ.data?.configured ?? false; + // Password accounts must re-verify to reset (guards a hijacked session); Google-only accounts + // have no password to check. Reactive passive observer of the App-owned ["me"] query (enabled: + // false — App does the fetching), so hasPassword stays correct even if me resolves after mount. + const meQ = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false }); + const hasPassword = meQ.data?.has_password ?? false; + const [view, setView] = useState<"gate" | "reset">("gate"); const [pass, setPass] = useState(""); const [confirm, setConfirm] = useState(""); + const [curPass, setCurPass] = useState(""); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); @@ -43,9 +54,79 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa } } + function toReset() { + setErr(null); + setPass(""); + setConfirm(""); + setView("reset"); + } + function cancelReset() { + setErr(null); + setCurPass(""); + setView("gate"); + } + + async function doReset() { + setErr(null); + if (hasPassword && !curPass) return setErr(t("messages.resetNeedPass")); + setBusy(true); + try { + await api.resetMessageKey(hasPassword ? curPass : undefined); + // Server key + dead history are gone; wipe this device's identity too so the gate re-appears + // in setup mode. Invalidate last so the refetch sees configured:false. + await e2ee.resetLocal(meId); + setCurPass(""); + setView("gate"); + qc.invalidateQueries({ queryKey: ["message-key"] }); + } catch (e) { + setErr(e instanceof HttpError && e.detail ? e.detail : t("messages.resetFailed")); + } finally { + setBusy(false); + } + } + if (keyQ.isLoading) return
{t("common.loading")}
; + if (view === "reset") + return ( +
+
+ +
{t("messages.resetTitle")}
+
+

{t("messages.resetWarn")}

+ {hasPassword && ( + setCurPass(e.target.value)} + placeholder={t("messages.resetCurrentPass")} + className={inputCls} + /> + )} + {err &&
{err}
} +
+ + +
+
+ ); + return (
@@ -65,7 +146,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa onChange={(e) => setPass(e.target.value)} onKeyDown={(e) => e.key === "Enter" && configured && submit()} placeholder={t("messages.passphrase")} - className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" + className={inputCls} /> {!configured && ( setConfirm(e.target.value)} placeholder={t("messages.passphraseConfirm")} - className="w-full bg-card border border-border rounded-xl px-3 py-2 text-sm outline-none focus:border-accent" + className={inputCls} /> )} {err &&
{err}
} @@ -85,6 +166,11 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa {configured ? t("messages.unlock") : t("messages.setUp")} + {configured && ( + + )} {!configured && !compact &&

{t("messages.setupWarn")}

}
); diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index aa8be27..508df8a 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -88,6 +88,8 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } const { t } = useTranslation(); const confirm = useConfirm(); const [copied, setCopied] = useState(false); + const [editPass, setEditPass] = useState(false); + const [newPass, setNewPass] = useState(""); const fullUrl = window.location.origin + link.url; const copy = async () => { @@ -106,6 +108,16 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } mutationFn: () => api.revokeDownloadLink(link.id), onSuccess: onChanged, }); + // Set / change / clear the link password. Rotating (or clearing) it bumps the link's + // password_version server-side, which invalidates any watch grant issued under the old password. + const savePass = useMutation({ + mutationFn: (pw: string) => api.updateDownloadLink(link.id, { password: pw }), + onSuccess: () => { + setEditPass(false); + setNewPass(""); + onChanged(); + }, + }); const badges: string[] = []; if (link.has_password) badges.push(t("downloads.share.hasPassword")); @@ -131,6 +143,23 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void } > {copied ? : } +
+ {editPass && ( +
+ setNewPass(e.target.value)} + onKeyDown={(e) => + e.key === "Enter" && newPass.trim() && savePass.mutate(newPass.trim()) + } + placeholder={t( + link.has_password ? "downloads.share.newPassword" : "downloads.share.setPassword", + )} + className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" + /> + + {link.has_password && ( + + )} +
+ )} ); } diff --git a/frontend/src/components/Welcome.tsx b/frontend/src/components/Welcome.tsx index f899cd4..313c3ff 100644 --- a/frontend/src/components/Welcome.tsx +++ b/frontend/src/components/Welcome.tsx @@ -24,7 +24,24 @@ import LanguageSwitcher from "./LanguageSwitcher"; const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; const PASSWORD_MIN = 10; -type Mode = "signin" | "register" | "forgot" | "reset"; +type Mode = "signin" | "register" | "forgot" | "reset" | "resend"; + +// Per-mode i18n keys for the card title and the submit button. A flat map (vs nested ternaries) +// keeps adding a new mode a one-line change and makes a missing key a compile error. +const TITLE_KEY: Record = { + signin: "welcome.auth.signinTitle", + register: "welcome.auth.createTitle", + forgot: "welcome.auth.forgotTitle", + reset: "welcome.auth.resetTitle", + resend: "welcome.auth.resendTitle", +}; +const SUBMIT_KEY: Record = { + signin: "welcome.auth.signinButton", + register: "welcome.auth.createButton", + forgot: "welcome.auth.forgotButton", + reset: "welcome.auth.resetButton", + resend: "welcome.auth.resendButton", +}; // Public landing + authentication. One scrollable page: a hero with the auth card (email+ // password, Google, explicit demo) over a short marketing pitch with feature cards. Shown @@ -267,8 +284,10 @@ function AuthCard() { const resetToken = hashParams.get("reset"); const verifyToken = hashParams.get("verify"); // raw token → POST to confirm (new flow) const bounced = params.get("access"); // Google denied → "requested" | "denied" - const login = params.get("login"); // Google login blocked → "suspended" const deleted = params.get("deleted"); // self-service account deletion just completed + // Google login outcome → "suspended" | "oauth_cancelled". State (not a const) so it clears on the + // first deliberate action, like the verify banner — otherwise it lingers stalely on later screens. + const [login, setLogin] = useState(() => params.get("login")); // Verification outcome: from POSTing the fragment token (new flow), or the legacy // ?verify=ok|invalid redirect (pre-SB3 emails still in flight). const [verify, setVerify] = useState(() => params.get("verify")); @@ -321,13 +340,26 @@ function AuthCard() { const reset = () => { setError(""); setDone(""); + // The verify/login banners are one-time entry notices — dismiss once the user takes any + // deliberate action (mode switch, CTA, submit) so they don't linger stalely on later screens. + setVerify(null); + setLogin(null); + }; + + // Shared by the expired-link banner CTA and the sign-in "didn't get the email?" link. + const goResend = () => { + reset(); + setMode("resend"); }; async function onSubmit(e: React.FormEvent) { e.preventDefault(); reset(); const mail = email.trim().toLowerCase(); - if ((mode === "signin" || mode === "register" || mode === "forgot") && !EMAIL_RE.test(mail)) { + if ( + (mode === "signin" || mode === "register" || mode === "forgot" || mode === "resend") && + !EMAIL_RE.test(mail) + ) { setError(t("welcome.auth.invalidEmail")); return; } @@ -348,6 +380,9 @@ function AuthCard() { } else if (mode === "forgot") { await api.requestPasswordReset(mail); setDone(t("welcome.auth.forgotDone")); + } else if (mode === "resend") { + await api.resendVerification(mail); + setDone(t("welcome.auth.resendDone")); } else if (mode === "reset") { await api.confirmPasswordReset(resetToken ?? "", password); setDone(t("welcome.auth.resetDone")); @@ -383,14 +418,7 @@ function AuthCard() { const inputCls = "w-full bg-card border border-border rounded-xl px-3 py-2.5 text-sm outline-none focus:border-accent"; - const titleKey = - mode === "register" - ? "welcome.auth.createTitle" - : mode === "forgot" - ? "welcome.auth.forgotTitle" - : mode === "reset" - ? "welcome.auth.resetTitle" - : "welcome.auth.signinTitle"; + const titleKey = TITLE_KEY[mode]; return (
@@ -398,10 +426,20 @@ function AuthCard() { {/* One-time status banners from a redirect (Google-denied, email verification). */} {verify === "ok" && {t("welcome.auth.verifyOk")}} - {verify === "invalid" && {t("welcome.auth.verifyInvalid")}} + {verify === "invalid" && ( + + {t("welcome.auth.verifyInvalid")}{" "} + + + )} {bounced === "requested" && {t("welcome.auth.accessRequested")}} {bounced === "denied" && {t("welcome.auth.accessDenied")}} {login === "suspended" && {t("welcome.auth.suspended")}} + {login === "oauth_cancelled" && ( + {t("welcome.auth.oauthCancelled")} + )} {deleted && {t("welcome.auth.deleted")}} {done ? ( @@ -431,7 +469,7 @@ function AuthCard() { className={inputCls} /> )} - {mode !== "forgot" && ( + {mode !== "forgot" && mode !== "resend" && ( - {busy - ? t("welcome.auth.working") - : t( - mode === "register" - ? "welcome.auth.createButton" - : mode === "forgot" - ? "welcome.auth.forgotButton" - : mode === "reset" - ? "welcome.auth.resetButton" - : "welcome.auth.signinButton", - )} + {busy ? t("welcome.auth.working") : t(SUBMIT_KEY[mode])} @@ -504,6 +532,14 @@ function AuthCard() { {mode === "signin" && ( <> + {/* A verification email can be lost or never arrive, so offer a resend path from + sign-in too — not only from the expired-link banner (needs a broken link to show). */} +
{t("welcome.auth.or")} diff --git a/frontend/src/i18n/locales/en/downloads.json b/frontend/src/i18n/locales/en/downloads.json index 531c2df..f93f8de 100644 --- a/frontend/src/i18n/locales/en/downloads.json +++ b/frontend/src/i18n/locales/en/downloads.json @@ -118,7 +118,13 @@ "views": "{{n}} views", "expiresOn": "expires {{date}}", "hasPassword": "password", - "downloadable": "downloadable" + "downloadable": "downloadable", + "setPassword": "Set a password", + "changePassword": "Change password", + "newPassword": "New password", + "savePassword": "Save", + "removePassword": "Remove", + "removePasswordConfirm": "This makes the link public — anyone with the URL can watch it without a password." }, "confirm": { "cancelTitle": "Cancel download?", diff --git a/frontend/src/i18n/locales/en/messages.json b/frontend/src/i18n/locales/en/messages.json index 249ec95..68c482b 100644 --- a/frontend/src/i18n/locales/en/messages.json +++ b/frontend/src/i18n/locales/en/messages.json @@ -31,5 +31,12 @@ "passTooShort": "Use at least 6 characters.", "passMismatch": "The passphrases don't match.", "wrongPass": "Wrong passphrase.", - "setupFailed": "Couldn't set up secure messaging. Please try again." + "setupFailed": "Couldn't set up secure messaging. Please try again.", + "forgotPass": "Forgot your passphrase?", + "resetTitle": "Reset secure messaging", + "resetWarn": "This permanently erases your entire message history — every conversation, for you and the people you messaged — because it can no longer be decrypted. Then you can set a new passphrase and start fresh. This can't be undone.", + "resetCurrentPass": "Current account password", + "resetNeedPass": "Enter your current account password to confirm.", + "resetButton": "Reset & erase history", + "resetFailed": "Couldn't reset secure messaging. Please try again." } diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 0ce4a0e..48edcae 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -80,6 +80,7 @@ "mismatch": "That's a different Google account than the one already linked here. Sign in with the linked one.", "error": "Couldn't link the Google account. Please try again." }, + "googleCancelled": "Google sign-in was cancelled.", "password": { "title": "Password", "setHint": "A password is set. You can sign in with your email and password.", diff --git a/frontend/src/i18n/locales/en/welcome.json b/frontend/src/i18n/locales/en/welcome.json index 40daa11..1d6b39e 100644 --- a/frontend/src/i18n/locales/en/welcome.json +++ b/frontend/src/i18n/locales/en/welcome.json @@ -57,6 +57,9 @@ "createButton": "Create account", "forgotButton": "Send reset link", "resetButton": "Set password", + "resendButton": "Resend verification", + "resendTitle": "Resend verification email", + "resendLink": "Didn't get the verification email?", "createLink": "Create an account", "forgotLink": "Forgot password?", "backToSignin": "Back to sign in", @@ -72,6 +75,9 @@ "resetDone": "Your password has been set — you can sign in now.", "verifyOk": "Email confirmed. Once an admin approves your account, you can sign in.", "verifyInvalid": "That verification link is invalid or has expired.", + "resendCta": "Send a new link", + "resendDone": "If that email has an unverified account, we've sent a new verification link. Check your inbox.", + "oauthCancelled": "Google sign-in was cancelled. You can try again or use email and password.", "accessRequested": "Thanks — we recorded your request. An admin will review it.", "accessDenied": "That account isn't approved for this instance yet — register below or ask the admin for access.", "suspended": "This account has been suspended and can't sign in. If you think this is a mistake, contact the operator (check your email for details).", diff --git a/frontend/src/i18n/locales/hu/downloads.json b/frontend/src/i18n/locales/hu/downloads.json index dc49c2c..831561d 100644 --- a/frontend/src/i18n/locales/hu/downloads.json +++ b/frontend/src/i18n/locales/hu/downloads.json @@ -118,7 +118,13 @@ "views": "{{n}} megtekintés", "expiresOn": "lejár: {{date}}", "hasPassword": "jelszó", - "downloadable": "letölthető" + "downloadable": "letölthető", + "setPassword": "Jelszó beállítása", + "changePassword": "Jelszó módosítása", + "newPassword": "Új jelszó", + "savePassword": "Mentés", + "removePassword": "Törlés", + "removePasswordConfirm": "Ezzel a link publikus lesz — jelszó nélkül bárki megnézheti, akinek megvan az URL." }, "confirm": { "cancelTitle": "Megszakítod a letöltést?", diff --git a/frontend/src/i18n/locales/hu/messages.json b/frontend/src/i18n/locales/hu/messages.json index 3ed312e..8588a0c 100644 --- a/frontend/src/i18n/locales/hu/messages.json +++ b/frontend/src/i18n/locales/hu/messages.json @@ -31,5 +31,12 @@ "passTooShort": "Legalább 6 karakter legyen.", "passMismatch": "A jelszavak nem egyeznek.", "wrongPass": "Hibás jelszó.", - "setupFailed": "A biztonságos üzenetküldés beállítása nem sikerült. Próbáld újra." + "setupFailed": "A biztonságos üzenetküldés beállítása nem sikerült. Próbáld újra.", + "forgotPass": "Elfelejtetted a jelszavad?", + "resetTitle": "Biztonságos üzenetküldés visszaállítása", + "resetWarn": "Ez véglegesen törli a teljes üzenet-előzményedet — minden beszélgetést, nálad és a partnereidnél is —, mert az többé nem visszafejthető. Utána új jelszót állíthatsz be és tiszta lappal indulhatsz. Ez nem visszavonható.", + "resetCurrentPass": "Jelenlegi fiók-jelszó", + "resetNeedPass": "Add meg a jelenlegi fiók-jelszavad a megerősítéshez.", + "resetButton": "Visszaállítás és előzmény törlése", + "resetFailed": "A biztonságos üzenetküldés visszaállítása nem sikerült. Próbáld újra." } diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index 582a787..7aad5df 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -80,6 +80,7 @@ "mismatch": "Ez nem az a Google-fiók, amely ide már össze van kapcsolva. Az összekapcsolttal jelentkezz be.", "error": "Nem sikerült összekapcsolni a Google-fiókot. Próbáld újra." }, + "googleCancelled": "A Google-belépés megszakadt.", "password": { "title": "Jelszó", "setHint": "Van beállított jelszó. Bejelentkezhetsz e-mail-címmel és jelszóval.", diff --git a/frontend/src/i18n/locales/hu/welcome.json b/frontend/src/i18n/locales/hu/welcome.json index 03a4689..1a80932 100644 --- a/frontend/src/i18n/locales/hu/welcome.json +++ b/frontend/src/i18n/locales/hu/welcome.json @@ -57,6 +57,9 @@ "createButton": "Fiók létrehozása", "forgotButton": "Visszaállító link küldése", "resetButton": "Jelszó beállítása", + "resendButton": "Megerősítő link újraküldése", + "resendTitle": "Megerősítő e-mail újraküldése", + "resendLink": "Nem kaptad meg a megerősítő e-mailt?", "createLink": "Hozz létre egy fiókot", "forgotLink": "Elfelejtetted a jelszót?", "backToSignin": "Vissza a belépéshez", @@ -72,6 +75,9 @@ "resetDone": "A jelszavad be van állítva — most már beléphetsz.", "verifyOk": "E-mail megerősítve. Amint egy admin jóváhagyja a fiókodat, beléphetsz.", "verifyInvalid": "Ez a megerősítő link érvénytelen vagy lejárt.", + "resendCta": "Új link küldése", + "resendDone": "Ha ehhez az e-mailhez tartozik megerősítetlen fiók, elküldtünk egy új megerősítő linket. Nézd meg a postafiókod.", + "oauthCancelled": "A Google-belépés megszakadt. Próbáld újra, vagy lépj be e-maillel és jelszóval.", "accessRequested": "Köszönjük — rögzítettük a kérésed. Egy admin felülvizsgálja.", "accessDenied": "Ez a fiók még nincs jóváhagyva ehhez a példányhoz — regisztrálj lent, vagy kérj hozzáférést az admintól.", "suspended": "Ez a fiók fel van függesztve, nem tud belépni. Ha tévedésnek gondolod, keresd az üzemeltetőt (a részletek az e-mailedben).", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 94b47c9..0240262 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1557,6 +1557,14 @@ export const api = { // Confirm an email-verification token the SPA read from the URL fragment (SB3). verifyEmail: (token: string): Promise<{ ok: boolean }> => req("/auth/verify", { method: "POST", body: JSON.stringify({ token }) }, { quiet: true }), + // Re-issue a verification link (the first one expired, or was lost). Uniform response — never + // reveals whether the email has a pending account. + resendVerification: (email: string): Promise<{ status: string }> => + req( + "/auth/verify/resend", + { method: "POST", body: JSON.stringify({ email }) }, + { quiet: true }, + ), // SA4: sign every OTHER session for this account out (keeps the current one). logoutOthers: (): Promise<{ ok: boolean }> => req("/auth/logout-others", { method: "POST" }), // Set or change the signed-in account's password (errors shown inline via quiet). @@ -1615,6 +1623,14 @@ export const api = { messageMyKey: (): Promise => req("/api/messages/keys/me"), setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> => req("/api/messages/keys", { method: "POST", body: JSON.stringify(k) }), + // Forgotten-passphrase reset: drops the server key (and the now-undecryptable history) so a new + // passphrase can be set. Password accounts must pass their current password; errors shown inline. + resetMessageKey: (currentPassword?: string): Promise<{ configured: boolean }> => + req( + "/api/messages/keys/reset", + { method: "POST", body: JSON.stringify({ current_password: currentPassword ?? null }) }, + { quiet: true }, + ), messagePublicKey: (userId: number): Promise<{ user_id: number; public_key: string }> => req(`/api/messages/keys/${userId}`), messageUsers: (): Promise => req("/api/messages/users"), diff --git a/frontend/src/lib/e2ee.ts b/frontend/src/lib/e2ee.ts index 9ef51e7..2e51522 100644 --- a/frontend/src/lib/e2ee.ts +++ b/frontend/src/lib/e2ee.ts @@ -85,6 +85,16 @@ export async function clearDevice(userId: number): Promise { }); } +// Wipe this device's E2EE identity: forget the unlocked key + every derived conversation key, +// delete the persisted copy, and notify observers so the gate re-appears. Used by the passphrase +// reset, which also drops the server key so the next screen is a fresh setup. +export async function resetLocal(userId: number): Promise { + privKey = null; + convKeys.clear(); + await clearDevice(userId); + emitUnlock(); +} + // --- key derivation --- async function wrapKeyFrom(passphrase: string, salt: Uint8Array): Promise { const base = await subtle.importKey("raw", bsrc(enc.encode(passphrase)), "PBKDF2", false, [ @@ -175,6 +185,13 @@ export async function unlock( await installKey(userId, await importPrivate(pkcs8)); } +// Drop the cached derived conversation key for a partner. convKeyFor caches by partnerId alone, so +// after a partner rotates their public key (a passphrase reset) the caller must call this before +// re-deriving with the fresh key — otherwise the stale key would be returned. +export function forgetConv(partnerId: number): void { + convKeys.delete(partnerId); +} + // --- per-conversation encryption --- async function convKeyFor(partnerId: number, partnerPubB64: string): Promise { const cached = convKeys.get(partnerId); diff --git a/frontend/src/lib/messaging.ts b/frontend/src/lib/messaging.ts index a532db2..8bdcb05 100644 --- a/frontend/src/lib/messaging.ts +++ b/frontend/src/lib/messaging.ts @@ -2,14 +2,16 @@ import { useSyncExternalStore } from "react"; import { useQuery } from "@tanstack/react-query"; import { api } from "./api"; -import { isUnlocked, subscribeUnlock } from "./e2ee"; +import { forgetConv, isUnlocked, subscribeUnlock } from "./e2ee"; export const SYSTEM_ID = 0; // synthetic "Siftlode" system conversation partner id // Safety-net poll interval for message queries; live updates arrive over the WebSocket. export const POLL_MS = 20000; -// Set-once cache of partner public keys (the server is the directory; keys never change). +// Cache of partner public keys (the server is the directory). Keys are stable in normal use, but a +// partner can rotate theirs via a passphrase reset — refreshPartnerPub() evicts a stale entry (and +// its derived conversation key) so the next fetch picks up the new key. const pubCache = new Map(); export async function partnerPub(partnerId: number): Promise { const cached = pubCache.get(partnerId); @@ -19,6 +21,22 @@ export async function partnerPub(partnerId: number): Promise { return r.public_key; } +// Drop a partner's cached public key + derived conversation key and refetch. Called after a decrypt +// fails, which can mean the partner reset their passphrase and published a NEW keypair; refetching +// lets this device both read their new messages and encrypt future ones under the current key. +// +// KNOWN LIMITATION: this refresh is wired only to the DECRYPT path. If you send a partner a message +// AFTER they rotated their key but BEFORE any of their messages arrived to trigger a refresh, that +// one send is encrypted under the stale cached key and is undecryptable for them (subsequent sends +// self-heal once an incoming message refreshes the cache). Accepted for now — same cross-user +// staleness envelope as the multi-device note in useKeyState; a fuller fix needs a key TTL / a +// send-time freshness check, which is out of the current no-forward-secrecy scope. +export async function refreshPartnerPub(partnerId: number): Promise { + pubCache.delete(partnerId); + forgetConv(partnerId); + return partnerPub(partnerId); +} + // Live "is secure messaging unlocked on this device" — shared so the page and the dock agree. function useUnlocked(): boolean { return useSyncExternalStore(subscribeUnlock, isUnlocked, isUnlocked); @@ -28,6 +46,14 @@ function useUnlocked(): boolean { // local unlock state gives the real gate: "setup" if the server has no key (even if a stale // private key lingers in this browser — setup overwrites it), "unlock" if the server has a key // but this device hasn't unlocked it, else "ready". +// +// KNOWN LIMITATION (multi-device, passphrase reset): this doesn't compare the locally-unlocked +// key's public half against the server's current public_key, so if the user resets their passphrase +// on ANOTHER device (new keypair), this already-unlocked device stays "ready" with the now-dead old +// key and silently fails to decrypt new messages until it's manually re-unlocked. Accepted for now: +// it fits the E2EE scope (static pairwise key, no forward secrecy) and self-heals once the device is +// re-unlocked with the new passphrase. Incoming-message decrypt failures on the CURRENT device DO +// self-heal via refreshPartnerPub() (a partner's rotation), which covers the common single-device case. export type KeyState = "loading" | "setup" | "unlock" | "ready"; export function useKeyState(): KeyState { const unlocked = useUnlocked(); diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 920dcc0..fce6092 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,24 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.53.0", + date: "2026-07-23", + summary: + "Account & sign-in dead ends fixed, a way out of a forgotten messaging passphrase, and share-link password controls — plus security hardening.", + features: [ + "Forgot your secure-messaging passphrase? You can now reset it from the unlock screen and start fresh with a new one. Resetting permanently clears your existing message history (it can't be decrypted without the old passphrase) — you're warned clearly before it happens.", + "Didn't get your verification email, or the link expired? You can now request a new one right from the sign-in screen.", + "Share links: you can now change or remove the password on an existing link, not just when creating it. Changing it immediately stops the old password from working.", + ], + fixes: [ + "Completing a password reset now also verifies your email — so an account that never clicked the original verification link isn't left stuck.", + "Cancelling Google sign-in now returns you to the login page with a clear message instead of a raw error page; the same for cancelling a Google connect/upgrade from inside the app.", + ], + chores: [ + "Security hardening: rotating a share-link password now instantly revokes links already handed out; the share recipient list and messaging key lookup no longer reveal suspended/deactivated accounts; and assorted race-condition and abuse-surface fixes.", + ], + }, { version: "0.52.0", date: "2026-07-22", diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 1026675..1048b8d 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -5,10 +5,15 @@ import react from "@vitejs/plugin-react"; // Use 127.0.0.1 (not "localhost") so Node doesn't resolve to IPv6 ::1, which the // Docker port publish may not answer — that surfaces as proxy connection failures. const target = "http://127.0.0.1:8080"; +// changeOrigin:false keeps the original `Host: localhost:5173` header when proxying to the backend +// (the TCP target is still 127.0.0.1:8080 — changeOrigin only rewrites the header). The backend uses +// that header to send the OAuth redirect_uri back to :5173, so a Google login started on the Vite dev +// server returns to :5173 instead of the fixed :8080 callback. (Both are registered in the console.) +const proxyOpts = { target, changeOrigin: false }; const proxy = { - "/api": target, - "/auth": target, - "/healthz": target, + "/api": proxyOpts, + "/auth": proxyOpts, + "/healthz": proxyOpts, }; export default defineConfig({