summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-23 19:01:37 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-23 19:01:37 +0200
commit3a27a5cd3ae1e752b17d27844cee3d18a35d4498 (patch)
tree5af139b818db052939e532f475206ad3df68d063
parent1f6b67c7bfc8078991a4607bb0419aea15bfd54c (diff)
downloadmeshbay-3a27a5cd3ae1e752b17d27844cee3d18a35d4498.tar.gz
fix(hub): an invitation no longer holds back its invitee's sign-up code
The per-recipient cooldown was shared by every purpose, so the code a person asked for by registering within two minutes of an invitation link was refused, silently. The cooldown is now per family (invitations vs the account's own steps); the daily cap still counts everything. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-rw-r--r--docs/MESHBAY_DESIGN.md2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/mail.py28
-rw-r--r--packages/meshbay-hub/tests/test_mail_is_not_a_relay.py50
-rw-r--r--packaging/conf/hub.toml.example5
4 files changed, 71 insertions, 14 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index a9653b6..673ad05 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -3095,7 +3095,7 @@ had already been asked.
| **AV8** | **One account cannot make the hub mail another at will.** The invitation email's subject comes from the group row, never from the request, and the endpoint is metered |
| **AV9** | **No mail is sent from the event loop.** `smtplib` is synchronous and waits up to ten seconds; called from an async handler that wait is the whole instance's, not one request's. Every send goes through `mail.send_off_loop`. **Argon2 is held to the same rule**: every derivation runs on one dedicated worker thread (`auth.*_off_loop`), never on the loop and never two at a time, because two concurrent `lanes=4` derivations deadlock in OpenSSL. **So is the node's disk**: every filesystem call on a group's content — the stat as much as the read, since a stat is what wakes a sleeping disk — goes through `roots.off_disk`, onto one worker thread per root set. A spun-down or network-mounted root answers its first syscall in seconds, and on the loop that is every group, every stream and the hub socket waiting for a platter. **ffmpeg's own output too**, through `asyncio.to_thread` rather than that per-root thread: a temp file is not a group root and has no platter to serialise against, but a whole transcode read inline is still tens of megabytes of blocking read |
| **AV10** | **Every path that makes the hub send mail is metered, per account.** A rate limit that counts by IP bounds a caller, not an inbox. Changing one's address mails an arbitrary stranger, so it carries a cooldown *and* a daily ceiling; a reset request and a registration resend carry cooldowns |
-| **AV13** | **The mail server is not a relay, and `mail.py` is where that is decided.** Every message passes one function; `purpose` is keyword-required and checked against a closed list, so a helper that names anything else does not send and one that names nothing is a TypeError. Under it sit a bound per **recipient** — the thing a person being mail-bombed actually experiences, unmoved by which account, address or endpoint asks — and an instance-wide hourly ceiling, because registration is open and "per account" is a bound an attacker buys more of |
+| **AV13** | **The mail server is not a relay, and `mail.py` is where that is decided.** Every message passes one function; `purpose` is keyword-required and checked against a closed list, so a helper that names anything else does not send and one that names nothing is a TypeError. Under it sit a bound per **recipient** — the thing a person being mail-bombed actually experiences, unmoved by which account, address or endpoint asks — and an instance-wide hourly ceiling, because registration is open and "per account" is a bound an attacker buys more of. The recipient's daily cap counts every purpose; its **cooldown is per family**, invitations on one side and the account's own steps on the other, because one cooldown for both refused — silently — the sign-up code of everyone who registered within two minutes of receiving an invitation link |
| **AV11** | **A namespace a client writes into is closed, and its rows are capped.** The preference key space is an allow-list plus `default_tab:<group_id>` checked as a group id, the value is length-bounded, and the row count per account is bounded |
| **AV12** | **Every list has an upper bound on `limit` and a floor under `offset`.** Including the ones that take no authentication at all — the public group directory and the content blocklist |
| **AV16** | **A bound the operator can see and change, and that a restart does not forget.** The mail allowance lives in `mail_quota`, not in a module dict — a deploy used to hand out a fresh budget, and the hub is deployed often. The values are settings with defaults in `hub.toml` and a block in the admin panel, because the hour a budget runs out is not when anyone wants to edit a file and restart; `/v1/admin/mail` says how much of the hour is left, which was previously visible only as an absence of mail |
diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py
index d2659e0..d36a829 100644
--- a/packages/meshbay-hub/src/meshbay_hub/mail.py
+++ b/packages/meshbay-hub/src/meshbay_hub/mail.py
@@ -57,6 +57,21 @@ ALLOWED_PURPOSES = frozenset({
# sign-ups cannot lock out someone trying to recover their passphrase.
RECOVERY_PURPOSES = frozenset({"password_reset", "invite"})
+# Which messages share a recipient's cooldown. Within a family the cooldown is
+# shared, so a burst cannot walk around it by switching purpose. Across the two
+# it is not, because they are two halves of one conversation: an invitation
+# arrives, the invitee registers a minute later, and the code they asked for
+# was refused — silently — by the cooldown the invitation had just armed. An
+# invitation link made that the normal case rather than a rare one. The daily
+# cap per recipient still counts every message, whichever family.
+# Someone else writes to you about a group; everything else is a step of your
+# own account's (a sign-up code, a reset, an address change).
+_INVITATIONS = frozenset({"invite", "invite_link"})
+
+
+def _cooldown_family(purpose: str) -> str:
+ return "invitation" if purpose in _INVITATIONS else "account"
+
def destination_key(address: str) -> str:
"""A stable handle for one recipient that is not the address itself.
@@ -204,13 +219,16 @@ async def reserve(db, purpose: str, address: str, *, sender: str = "") -> None:
EXHAUSTED_ALL if purpose in RECOVERY_PURPOSES else EXHAUSTED_GENERAL)
raise
- # Then the recipient: across every purpose, account and endpoint. This is
- # what a person being mail-bombed actually experiences, and the only bound
- # that describes it.
+ # Then the recipient. The cooldown per family (above) first, so a message
+ # refused for coming too soon does not also spend the day's allowance; then
+ # the daily cap, across every purpose, account and endpoint — what a person
+ # being mail-bombed actually experiences, and the only bound that says so.
+ dest = destination_key(address)
await _take(
- db, destination_key(address), timedelta(days=1),
- limits["destination_daily_cap"],
+ db, f"cool:{_cooldown_family(purpose)}:{dest[len('dest:'):]}",
+ timedelta(days=1), 10**9,
timedelta(seconds=limits["destination_cooldown_seconds"]))
+ await _take(db, dest, timedelta(days=1), limits["destination_daily_cap"], None)
async def status(db) -> dict:
diff --git a/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py b/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py
index a3f9a21..157dbd5 100644
--- a/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py
+++ b/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py
@@ -148,21 +148,57 @@ async def test_one_recipient_has_a_cooldown_and_a_daily_allowance(db_session):
"a second purpose walked around the cooldown")
cap = MailConfig().destination_daily_cap
- key = mail_mod.destination_key(victim)
+ cool = "cool:invitation:" + mail_mod.destination_key(victim)[len("dest:"):]
+
+ async def past_the_cooldown():
+ # Step past it without waiting it out. The daily count is deliberately
+ # left alone.
+ row = await db_session.get(MailQuota, cool)
+ if row:
+ row.last_sent = None
+ await db_session.commit()
+
for _ in range(cap - 1):
- # Step past the cooldown without waiting it out. The daily count is
- # deliberately left alone.
- (await db_session.get(MailQuota, key)).last_sent = None
- await db_session.commit()
+ await past_the_cooldown()
assert await _charge(db_session, "invite", victim)
- (await db_session.get(MailQuota, key)).last_sent = None
- await db_session.commit()
+ await past_the_cooldown()
assert not await _charge(db_session, "invite", victim), (
f"the {cap}-a-day allowance for one recipient was not enforced")
@pytest.mark.asyncio
+async def test_an_invitation_does_not_stop_the_code_its_invitee_asks_for(db_session):
+ """
+ The regression: an invitation link arrives, the invitee registers a minute
+ later, and the verification code was refused — silently — by the cooldown
+ the invitation had just armed. Nobody invited by link could sign up without
+ waiting two minutes and then asking again, with nothing on screen saying so.
+
+ The two are one conversation, so they do not share a cooldown; each family
+ still keeps its own, and the daily cap still counts both.
+ """
+ async def charge(purpose, addr):
+ try:
+ await mail_mod.reserve(db_session, purpose, addr, sender="owner-1")
+ except mail_mod.MailRefused:
+ await db_session.rollback()
+ return False
+ await db_session.commit()
+ return True
+
+ for invitation in ("invite", "invite_link"):
+ addr = f"newcomer-{invitation}@example.test"
+ assert await charge(invitation, addr)
+ assert await charge("registration", addr), (
+ f"a verification code was refused after an {invitation} mail")
+ assert not await charge("password_reset", addr), (
+ "within the account family the cooldown must still hold")
+ assert not await charge("invite", addr), (
+ "a second invitation in the same burst must still wait")
+
+
+@pytest.mark.asyncio
async def test_the_same_recipient_is_one_recipient_however_it_is_written(db_session):
"""Case and surrounding space are not a new person."""
assert await _charge(db_session, "registration", "Victim@Example.Test")
diff --git a/packaging/conf/hub.toml.example b/packaging/conf/hub.toml.example
index fc05591..640b2ce 100644
--- a/packaging/conf/hub.toml.example
+++ b/packaging/conf/hub.toml.example
@@ -107,7 +107,10 @@ hourly_budget = 200
# `hourly_budget - hourly_reserved_for_recovery`.
hourly_reserved_for_recovery = 50
-# Per recipient, across every purpose, account and endpoint. This is the bound
+# Per recipient. The daily cap counts every purpose, account and endpoint; the
+# cooldown is shared within two families — invitations, and an account's own
+# steps (sign-up code, reset, address change) — so an invitation does not hold
+# back the sign-up code its invitee asks for a minute later. This is the bound
# that describes what a person being flooded actually receives, and the only
# one that does.
destination_daily_cap = 10