diff --git a/SECURITY.md b/SECURITY.md index 55f39d34..32c5954a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -49,6 +49,11 @@ provider account and bypass this platform ownership table. (`TREG_BLOCKED_EMAIL_DOMAINS`, unset means no blocks) remains a configurable speed bump at every sign-in/sign-up door and both team-creating endpoints; it fails open on classifier errors. Suspend abusive users and teams separately, retaining their records for investigation. +- **Designated sign-in codes are a password, stored only as a hash.** `TREG_FIXED_LOGIN_CODES` + (unset by default) maps an email with no inbox, such as an app directory reviewer's demo account, + to the SHA-256 of a fixed code. The email-code door then sends nothing and accepts only that code, + under the same five-guess and start limits as an emailed code. Use a long random code, give the + account a dedicated team with sample data only, and rotate by changing the hash. ## Archive object storage credentials diff --git a/docs/context/interface/api.md b/docs/context/interface/api.md index 6d69fc8f..9d0ada2c 100644 --- a/docs/context/interface/api.md +++ b/docs/context/interface/api.md @@ -450,8 +450,10 @@ validated before resolving the shared HTTP client. `/auth/logout` remains an HTT before returning CLI users to the team picker. - Email: `POST /auth/email/start` and `/verify`. Six-digit codes, attempt counts and per-email/ per-IP start limits live in DB-backed `Ephemeral` state. `expose_dev_code` permits response/ - log disclosure only on guarded local SQLite; other deployments email the code. Verification - issues an identity token and session cookie. + log disclosure only on guarded local SQLite; other deployments email the code. An email listed + in `TREG_FIXED_LOGIN_CODES` (an account with no inbox, such as a directory reviewer's demo + account) is issued its configured code hash instead and nothing is sent; attempts, TTL and + start limits are unchanged. Verification issues an identity token and session cookie. - Invite sign-in: the admin-visible invite code is join-only. An independent inbox-only `email_token` authenticates through `/auth/invite-signin`, consumed once. Invalid/expired links return `/?invite_expired=1`. See [auth-secrets](../architecture/auth-secrets.md). diff --git a/src/treg/application/auth.py b/src/treg/application/auth.py index 90a35452..698e6f09 100644 --- a/src/treg/application/auth.py +++ b/src/treg/application/auth.py @@ -222,14 +222,20 @@ async def start_email_login(email: str, client_ip: str) -> dict: ): await db.commit() raise EmailAuthError("rate_limited") + # A designated account has no inbox: its configured code hash is issued instead, so the + # verify path, attempt count and TTL are exactly those of an emailed code. + fixed_hash = get_settings().fixed_login_code_hashes.get(email) code = f"{_secrets.randbelow(1_000_000):06d}" await ratestore.kv_put( db, OTP_NS, email, - {"hash": crypto.hash_token(code), "attempts": MAX_OTP_ATTEMPTS}, EMAIL_CODE_TTL, + {"hash": fixed_hash or crypto.hash_token(code), "attempts": MAX_OTP_ATTEMPTS}, + EMAIL_CODE_TTL, ) await db.commit() result = {"sent": True, "email": email} + if fixed_hash: + return result if get_settings().expose_dev_code: print(f"[email-otp] {email} -> {code}") result["dev_code"] = code diff --git a/src/treg/config.py b/src/treg/config.py index c26d023b..1ca4dceb 100644 --- a/src/treg/config.py +++ b/src/treg/config.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from functools import lru_cache from typing import Literal @@ -32,6 +33,14 @@ def platform_setting_name(provider: str) -> str: return "platform_key_" + (provider or "").lower().replace("-", "_") +@lru_cache +def _fixed_login_codes(raw: str) -> dict[str, str]: + """Parse `TREG_FIXED_LOGIN_CODES` (`email=sha256hex,...`) into {normalised email: code hash}. + Malformed entries are refused by the field validator, so parsing here can trust the shape.""" + pairs = (part.split("=", 1) for part in raw.split(",") if part.strip()) + return {email.strip().lower(): digest.strip().lower() for email, digest in pairs} + + @lru_cache def _blocked_email_domains(raw: str) -> frozenset[str]: """Parse `TREG_BLOCKED_EMAIL_DOMAINS` once per distinct value, not per request: split on commas, @@ -563,6 +572,27 @@ class Settings(BaseSettings): # deliberately: no allowlist, no table, no admin UI. blocked_email_domains: str = "" + # Sign-in codes for designated accounts that cannot receive email, such as the demo account an + # app directory's reviewers use: `email=,...`. For a listed email the + # email-code door sends nothing and accepts only the configured code, under the same attempt and + # start limits as an emailed one. Only the hash is configured; use a long random code. Empty + # (the default) leaves every email on the normal emailed code. + fixed_login_codes: str = "" + + @field_validator("fixed_login_codes") + @classmethod + def _fixed_login_codes_shape(cls, v: str) -> str: + for part in (p for p in v.split(",") if p.strip()): + email, sep, digest = part.partition("=") + if not sep or "@" not in email or not re.fullmatch(r"[0-9a-fA-F]{64}", digest.strip()): + raise ValueError("fixed_login_codes entries must be email=<64-hex sha256>") + return v + + @property + def fixed_login_code_hashes(self) -> dict[str, str]: + """The normalised `TREG_FIXED_LOGIN_CODES` entries; empty = no designated accounts.""" + return _fixed_login_codes(self.fixed_login_codes) + @property def blocked_email_domain_set(self) -> frozenset[str]: """The normalised `TREG_BLOCKED_EMAIL_DOMAINS` entries; empty = nothing is blocked.""" diff --git a/tests/test_auth_email.py b/tests/test_auth_email.py index 400798dc..ba06fac8 100644 --- a/tests/test_auth_email.py +++ b/tests/test_auth_email.py @@ -121,3 +121,59 @@ async def test_social_signup_tracking_reuses_same_new_user_rule(client, monkeypa await _provision_social_user("social@example.test", "unused", method, "arena") assert len(events) == 1 assert events[0][1:] == ("signup_completed", {"signup_method": method, "entry_surface": "arena"}) + + +REVIEWER = "reviewer@example.com" +REVIEWER_CODE = "40718293561728394056" + + +@pytest.fixture +def fixed_code(monkeypatch): + import hashlib + digest = hashlib.sha256(REVIEWER_CODE.encode()).hexdigest() + monkeypatch.setattr(get_settings(), "fixed_login_codes", f"Reviewer@Example.com={digest}") + + +async def test_designated_account_signs_in_with_its_fixed_code_and_nothing_is_sent( + client, fixed_code, monkeypatch, +): + from treg import email as email_sender + sent: list[str] = [] + + async def record(email, code, **_): + sent.append(email) + + monkeypatch.setattr(get_settings(), "email_dev_mode", False) + monkeypatch.setattr(email_sender, "send_otp", record) + for _ in range(2): # the same code works on every sign-in, not once + start = await client.post("/auth/email/start", json={"email": REVIEWER}) + assert start.status_code == 200 and "dev_code" not in start.json() + ok = await client.post("/auth/email/verify", json={"email": REVIEWER, "code": REVIEWER_CODE}) + assert ok.status_code == 200, ok.text + assert sent == [] # a designated account has no inbox, so no code is ever mailed + + await client.post("/auth/email/start", json={"email": "someone@matrix.io"}) + assert sent == ["someone@matrix.io"] # every other email still gets its emailed code + + +async def test_designated_account_keeps_the_attempt_limit(client, fixed_code): + await client.post("/auth/email/start", json={"email": REVIEWER}) + from treg.application.auth import MAX_OTP_ATTEMPTS + for _ in range(MAX_OTP_ATTEMPTS): + bad = await client.post("/auth/email/verify", json={"email": REVIEWER, "code": "123456"}) + assert bad.status_code == 401 + # The issued code is spent after the allowed wrong guesses, so even the right code needs a new start. + spent = await client.post("/auth/email/verify", json={"email": REVIEWER, "code": REVIEWER_CODE}) + assert spent.status_code == 401 + await client.post("/auth/email/start", json={"email": REVIEWER}) + ok = await client.post("/auth/email/verify", json={"email": REVIEWER, "code": REVIEWER_CODE}) + assert ok.status_code == 200 + + +def test_fixed_login_codes_refuses_a_malformed_entry(): + from pydantic import ValidationError + from treg.config import Settings + with pytest.raises(ValidationError): + Settings(fixed_login_codes="reviewer@example.com=not-a-sha256") + with pytest.raises(ValidationError): + Settings(fixed_login_codes="reviewer@example.com")