Merge R4: auth funnel & abuse hardening

S1 dead ends (password-reset verifies email, resend-verification endpoint+CTA,
OAuth-cancel redirect + marker pop before exchange) · dev OAuth returns to the
origin that started it · S2 E2EE forgotten-passphrase reset (key-reset endpoint,
KeyGate danger-confirm, partner-key refresh on decrypt failure) · S3 abuse & leak
hardening (password_version share grants [migration 0059], _messageable filters
on share endpoints, GET /keys MB2 anti-enum, register IntegrityError no-op,
atomic scheduler single-run claim) · share-link password change/clear UI.
This commit is contained in:
2026-07-23 04:24:11 +02:00
30 changed files with 774 additions and 99 deletions
@@ -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 <token>.<exp>, 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")
+108 -14
View File
@@ -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 "<no email>")
@@ -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",
+9 -5
View File
@@ -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: `<exp>.<sig>`."""
def make_grant(token: str, password_version: int, now: float | None = None) -> str:
"""A short-lived HMAC grant proving the viewer unlocked `token`. Format: `<exp>.<sig>`. 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 -------------------------------------------------------------------------
+4
View File
@@ -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")
+10 -3
View File
@@ -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)
+63 -15
View File
@@ -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()
+3 -3
View File
@@ -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:
+28 -4
View File
@@ -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"
+22
View File
@@ -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)
+17 -10
View File
@@ -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:
+61
View File
@@ -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"
)
+24
View File
@@ -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()
+23
View File
@@ -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
+9
View File
@@ -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).
+25 -7
View File
@@ -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<number, string> = {};
// 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);
+91 -5
View File
@@ -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<string | null>(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 <div className="p-6 text-center text-muted text-sm">{t("common.loading")}</div>;
if (view === "reset")
return (
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-400 shrink-0" />
<div className="font-semibold text-sm">{t("messages.resetTitle")}</div>
</div>
<p className="text-sm text-muted">{t("messages.resetWarn")}</p>
{hasPassword && (
<input
type="password"
autoComplete="current-password"
value={curPass}
onChange={(e) => setCurPass(e.target.value)}
placeholder={t("messages.resetCurrentPass")}
className={inputCls}
/>
)}
{err && <div className="text-sm text-red-400">{err}</div>}
<div className="flex gap-2">
<button
onClick={cancelReset}
disabled={busy}
className="px-4 py-2 rounded-xl text-sm border border-border hover:border-accent transition disabled:opacity-40"
>
{t("common.cancel")}
</button>
<button
onClick={doReset}
disabled={busy}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold bg-red-500/90 text-white hover:bg-red-500 transition disabled:opacity-40"
>
<AlertTriangle className="w-4 h-4" />
{t("messages.resetButton")}
</button>
</div>
</div>
);
return (
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
<div className="flex items-center gap-2">
@@ -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 && (
<input
@@ -73,7 +154,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
value={confirm}
onChange={(e) => 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 && <div className="text-sm text-red-400">{err}</div>}
@@ -85,6 +166,11 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
<Lock className="w-4 h-4" />
{configured ? t("messages.unlock") : t("messages.setUp")}
</button>
{configured && (
<button onClick={toReset} className="block text-xs text-muted hover:text-fg">
{t("messages.forgotPass")}
</button>
)}
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
</div>
);
+72
View File
@@ -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 ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
</button>
<button
onClick={() => {
setNewPass("");
setEditPass((v) => !v);
}}
title={
link.has_password
? t("downloads.share.changePassword")
: t("downloads.share.setPassword")
}
className={clsx(
"p-1.5 rounded-md hover:bg-surface transition",
link.has_password ? "text-accent" : "text-muted hover:text-fg",
)}
>
<Lock className="w-4 h-4" />
</button>
<button
onClick={async () => {
if (
@@ -165,6 +194,49 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
{t("downloads.share.allowDownload")}
</label>
</div>
{editPass && (
<div className="flex items-center gap-2">
<input
type="password"
autoComplete="new-password"
value={newPass}
onChange={(e) => 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"
/>
<button
onClick={() => savePass.mutate(newPass.trim())}
disabled={savePass.isPending || !newPass.trim()}
className="px-2 py-1 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:opacity-90 disabled:opacity-40 transition"
>
{t("downloads.share.savePassword")}
</button>
{link.has_password && (
<button
onClick={async () => {
if (
await confirm({
title: t("downloads.share.removePassword"),
message: t("downloads.share.removePasswordConfirm"),
confirmLabel: t("downloads.share.removePassword"),
danger: true,
})
)
savePass.mutate("");
}}
disabled={savePass.isPending}
className="px-2 py-1 rounded-md text-xs text-muted hover:text-red-400 transition"
>
{t("downloads.share.removePassword")}
</button>
)}
</div>
)}
</div>
);
}
+60 -24
View File
@@ -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<Mode, string> = {
signin: "welcome.auth.signinTitle",
register: "welcome.auth.createTitle",
forgot: "welcome.auth.forgotTitle",
reset: "welcome.auth.resetTitle",
resend: "welcome.auth.resendTitle",
};
const SUBMIT_KEY: Record<Mode, string> = {
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<string | null>(() => 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<string | null>(() => 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 (
<div className="glass rounded-2xl p-6 sm:p-7 w-full max-w-md lg:justify-self-end">
@@ -398,10 +426,20 @@ function AuthCard() {
{/* One-time status banners from a redirect (Google-denied, email verification). */}
{verify === "ok" && <Banner tone="ok">{t("welcome.auth.verifyOk")}</Banner>}
{verify === "invalid" && <Banner tone="warn">{t("welcome.auth.verifyInvalid")}</Banner>}
{verify === "invalid" && (
<Banner tone="warn">
{t("welcome.auth.verifyInvalid")}{" "}
<button onClick={goResend} className="font-semibold text-accent hover:underline">
{t("welcome.auth.resendCta")}
</button>
</Banner>
)}
{bounced === "requested" && <Banner tone="ok">{t("welcome.auth.accessRequested")}</Banner>}
{bounced === "denied" && <Banner tone="warn">{t("welcome.auth.accessDenied")}</Banner>}
{login === "suspended" && <Banner tone="warn">{t("welcome.auth.suspended")}</Banner>}
{login === "oauth_cancelled" && (
<Banner tone="warn">{t("welcome.auth.oauthCancelled")}</Banner>
)}
{deleted && <Banner tone="ok">{t("welcome.auth.deleted")}</Banner>}
{done ? (
@@ -431,7 +469,7 @@ function AuthCard() {
className={inputCls}
/>
)}
{mode !== "forgot" && (
{mode !== "forgot" && mode !== "resend" && (
<input
type="password"
autoComplete={mode === "signin" ? "current-password" : "new-password"}
@@ -452,17 +490,7 @@ function AuthCard() {
disabled={busy}
className="px-4 py-2.5 rounded-xl font-semibold bg-accent text-accent-fg hover:opacity-90 disabled:opacity-50 transition"
>
{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])}
</button>
</form>
@@ -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). */}
<button
onClick={goResend}
className="mt-2 block w-full text-center text-xs text-muted hover:text-fg"
>
{t("welcome.auth.resendLink")}
</button>
<div className="flex items-center gap-3 my-4 text-[11px] uppercase tracking-wide text-muted">
<span className="h-px flex-1 bg-border" />
{t("welcome.auth.or")}
+7 -1
View File
@@ -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?",
+8 -1
View File
@@ -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."
}
@@ -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.",
@@ -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).",
+7 -1
View File
@@ -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?",
+8 -1
View File
@@ -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."
}
@@ -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.",
@@ -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).",
+16
View File
@@ -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<MyKeyBundle> => 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<MessageUser[]> => req("/api/messages/users"),
+17
View File
@@ -85,6 +85,16 @@ export async function clearDevice(userId: number): Promise<void> {
});
}
// 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<void> {
privKey = null;
convKeys.clear();
await clearDevice(userId);
emitUnlock();
}
// --- key derivation ---
async function wrapKeyFrom(passphrase: string, salt: Uint8Array): Promise<CryptoKey> {
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<CryptoKey> {
const cached = convKeys.get(partnerId);
+28 -2
View File
@@ -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<number, string>();
export async function partnerPub(partnerId: number): Promise<string> {
const cached = pubCache.get(partnerId);
@@ -19,6 +21,22 @@ export async function partnerPub(partnerId: number): Promise<string> {
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<string> {
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();
+8 -3
View File
@@ -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({