aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py42
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/deps.py38
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/hub.py9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/invite_links.py49
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py29
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py43
7 files changed, 196 insertions, 38 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
index b4b2f4f..381378c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -329,6 +329,25 @@ async def admin_patch_user(
raise HTTPException(
status_code=422,
detail="status must be active, suspended, or revoked")
+ # Revoking is not a status write. It is signed and broadcast to every
+ # node so the account is refused there at once; PATCH would change only
+ # the hub row and leave the nodes unaware — a "revoked" that is not the
+ # revoked the design promises (§7.5). One door, and it broadcasts.
+ # (A moderator was already refused above by `privileged`; this is the
+ # admin, who is told the right door rather than given a broken one.)
+ if body.status == "revoked":
+ raise HTTPException(
+ status_code=400,
+ detail="Revoke through POST /v1/admin/revoke — it signs the "
+ "revocation and broadcasts it to every node.")
+ # Leaving `revoked` undoes a signed, node-enforced action, so it is an
+ # admin's call, never a moderator's: the nodes still deny this account
+ # from the broadcast, and a moderator flipping the hub row back to
+ # active would only disagree with them.
+ if user.status == "revoked" and not user_is_admin(current_user):
+ raise HTTPException(
+ status_code=403,
+ detail="Only an admin can change a revoked account.")
user.status = body.status
log.info("User %s status changed to %s by %s",
user.username, body.status, current_user.username)
@@ -489,6 +508,29 @@ async def admin_patch_group(
raise HTTPException(
status_code=422,
detail="status must be active, suspended, or revoked")
+ # Suspend/unsuspend is a moderator's reversible, hub-only lever (§7.5).
+ # Revoke is neither: it is signed and broadcast to every node, and it is
+ # an administrative act — the same line `admin_patch_user` draws. Two
+ # gaps used to sit here: a moderator could set `revoked`, and a
+ # `revoked` set through this PATCH was never broadcast, so it behaved
+ # like `suspended` on nodes while claiming to be the signed, enforced
+ # state. Revoke has one door, `POST /v1/admin/revoke`, and it broadcasts.
+ if body.status == "revoked":
+ if not user_is_admin(current_user):
+ raise HTTPException(
+ status_code=403,
+ detail="Revoking a group requires admin rights.")
+ raise HTTPException(
+ status_code=400,
+ detail="Revoke a group through POST /v1/admin/revoke — it signs "
+ "the revocation and broadcasts it to every node.")
+ # Leaving `revoked` undoes that broadcast and is an admin's call: the
+ # nodes still enforce the revocation, and a moderator flipping the hub
+ # row back would only disagree with them.
+ if group.status == "revoked" and not user_is_admin(current_user):
+ raise HTTPException(
+ status_code=403,
+ detail="Only an admin can change a revoked group.")
group.status = body.status
log.info("Group %s status changed to %s by %s",
group.name, body.status, current_user.username)
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
index 501be9d..42f4101 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
@@ -59,16 +59,34 @@ async def get_current_user(
return user
-async def require_user_scope(
- payload: dict = Depends(_decode_token),
- current_user: User = Depends(get_current_user),
-) -> User:
- """Reject node-scoped tokens — only browser (user-scope) can mutate groups."""
+def _reject_node_scope(payload: dict) -> None:
+ """Refuse a node-scoped daemon token on a route meant for a person.
+
+ A node's authority and a hub role are **different notions**. What a node may
+ do is decided by its operator's roster pin on the node itself (NS4) and by
+ the node scope's deliberately narrow reach; being an admin or a moderator is
+ a hub role attached to a person's account. A node daemon authenticates with
+ the node key and receives a `scope:"node"` token so that the machine can
+ register, signal and host — never so that it can act as its operator on the
+ hub. When the operator's account happens to also hold a hub role, that role
+ is the *person's*, exercised from a browser with a user-scoped token, and
+ must not be reachable by a token the daemon holds in memory. So the scope
+ gate lives in one place and fronts every privileged dependency, not only
+ group mutation.
+ """
if payload.get("scope") == "node":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Node-scoped token cannot perform this operation — use browser",
)
+
+
+async def require_user_scope(
+ payload: dict = Depends(_decode_token),
+ current_user: User = Depends(get_current_user),
+) -> User:
+ """Reject node-scoped tokens — only browser (user-scope) can mutate groups."""
+ _reject_node_scope(payload)
return current_user
@@ -84,8 +102,13 @@ def user_is_moderator(user: User) -> bool:
async def require_moderator(
+ payload: dict = Depends(_decode_token),
current_user: User = Depends(get_current_user),
) -> User:
+ # A node-scoped daemon token is refused here even for a moderator's own
+ # account: the hub moderation surface (suspending accounts, reading the IP
+ # audit log, listing nodes) is the person's, not the machine's.
+ _reject_node_scope(payload)
if not user_is_moderator(current_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Moderator access required")
@@ -93,8 +116,13 @@ async def require_moderator(
async def require_admin(
+ payload: dict = Depends(_decode_token),
current_user: User = Depends(get_current_user),
) -> User:
+ # Likewise: revoking accounts and groups (signed, broadcast to every node)
+ # and changing instance policy are administrative acts a person performs
+ # from a browser, never something a node token may reach.
+ _reject_node_scope(payload)
if not user_is_admin(current_user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/hub.py b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
index cab60c8..efebc4d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/hub.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/hub.py
@@ -78,8 +78,13 @@ async def hub_pubkey():
# handshake refusal anyway. The operator is updating every client, node and hub
# by hand for this flag day, which is what makes that acceptable exactly once.
# The gate is in place for the next one, where it will work as intended.
-MIN_CLIENT_VERSION = "0.13.0"
-RECOMMENDED_CLIENT_VERSION = "0.13.0"
+#
+# 0.16.0 is the MNP 4.0 flag day: a client older than this presents its hub
+# session token to a node, which a 4.0 node refuses. A desktop client below
+# 0.16.0 is told to update *before* it connects rather than meeting a handshake
+# refusal it cannot read; the browser reloads this build from the hub.
+MIN_CLIENT_VERSION = "0.16.0"
+RECOMMENDED_CLIENT_VERSION = "0.16.0"
@router.get("/version")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py
index f33dc6e..3c50e19 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py
@@ -4,14 +4,13 @@ Invitation links — the hub's half (docs/MESHBAY_DESIGN.md §3.4, §7.3).
A link carries two secrets with two jobs. The node's code decides who gets the
group key, and it is shown to the hub only when the inviter asks the hub to mail
the link. The ticket here decides who may *reach* the node — membership,
-which is all the hub has to give (§7.1) — and it gives it to one account only:
-the one whose verified address the inviter named. A ticket that leaks, through a
-messaging service that previews links or a forwarded mail, is therefore useless
-without that mailbox.
+which is all the hub has to give (§7.1) — and it gives it to one account: the
+first to redeem it. Both halves are therefore bearer secrets, so the link can
+travel through any messaging service; what bounds a leaked one is that it works
+once, for seven days, and that the owner can cancel it.
-Stated per the design's convention: that binding holds against third parties and
-not against this hub, which verifies the addresses it compares. An active hub
-could already be anybody.
+The address is optional and binds nothing. When given, it is where the hub
+mails the link and a masked label in the owner's list.
No route here answers without an account, and nothing tells the inviter whether
an address has an account (M1): creating a link looks the same either way.
@@ -30,7 +29,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub import mail
from meshbay_hub.api.deps import _decode_token, get_current_user, require_user_scope
from meshbay_hub.api.middleware import limiter
-from meshbay_hub.auth import hash_email_blind
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import Group, GroupInviteLink, GroupMember, User
@@ -40,9 +38,9 @@ redeem_router = APIRouter(prefix="/v1/invite-links", tags=["invite-links"])
# The node holds at most as many unredeemed link codes per group; one more
# ticket here than codes there would be a link that cannot work.
MAX_OUTSTANDING_PER_GROUP = 20
-# A node's invitation lifetime is the operator's setting (7 days by default);
-# the ticket follows it, up to this.
-MAX_LIFETIME = timedelta(days=30)
+# The node issues a link's code for exactly this long; the ticket follows it,
+# and never outlives it.
+MAX_LIFETIME = timedelta(days=7)
# How long a spent link is kept before it is forgotten. The owner is not shown
# it — the person is in the group — but while the row is here, a reload or a
# second tab of the invitation page still answers the account that used it.
@@ -83,6 +81,8 @@ def _aware(when: datetime) -> datetime:
def _valid_email(v: str) -> str:
v = v.strip()
+ if not v:
+ return v
local, sep, domain = v.partition("@")
if (not sep or not local or not domain or "." not in domain.strip(".")
or len(v) > 254 or any(c.isspace() or ord(c) < 32 for c in v)):
@@ -91,7 +91,8 @@ def _valid_email(v: str) -> str:
class CreateLinkRequest(BaseModel):
- email: str
+ # Optional: only where the hub mails the link, and a label for the owner.
+ email: str = ""
expires_at: str
node_invite_id: str
# Only when the hub is to mail the link, since only then must it write it:
@@ -149,6 +150,8 @@ async def create_invite_link(
if not _NODE_INVITE_ID.match(body.node_invite_id):
raise HTTPException(status_code=422, detail="Not a node invitation id")
if body.send_email:
+ if not body.email:
+ raise HTTPException(status_code=422, detail="Mailing a link needs an address")
if payload.get("scope") == "node":
raise HTTPException(status_code=403,
detail="Invitation mail is sent from the interface only")
@@ -179,7 +182,7 @@ async def create_invite_link(
ticket = secrets.token_urlsafe(16)
row = GroupInviteLink(
group_id=group_id, created_by=current_user.id, ticket_hash=ticket_hash(ticket),
- email_hash=hash_email_blind(body.email), email_masked=_mask(body.email),
+ email_masked=_mask(body.email) if body.email else None,
node_invite_id=body.node_invite_id, expires_at=expires)
db.add(row)
await db.flush()
@@ -225,7 +228,7 @@ async def list_invite_links(
now = datetime.now(UTC)
return {"links": [{
"link_id": r.id,
- "email": r.email_masked,
+ "email": r.email_masked or "",
"node_invite_id": r.node_invite_id,
"created_at": _aware(r.created_at).isoformat(),
"expires_at": _aware(r.expires_at).isoformat(),
@@ -257,14 +260,10 @@ async def delete_invite_link(
async def _resolve(db: AsyncSession, ticket: str, user: User
) -> tuple[GroupInviteLink, Group]:
"""
- The link this ticket names, if this account may use it.
-
- One uniform refusal for everything that says nothing about the account —
- unknown, spent by someone else, expired, a group no longer active — and one
- distinct answer, `invite_other_account`, for the case the person can act
- on: signed in as somebody other than the address it was sent to. That
- answer names no address, and it is given only to someone holding the
- ticket, who already knows a link exists.
+ The link this ticket names, if this account may use it: any account while
+ nobody has, and afterwards only the one that did. One uniform refusal for
+ everything else — unknown, spent by someone else, expired, a group no
+ longer active.
"""
invalid = HTTPException(status_code=404, detail="invite_not_valid")
if not _TICKET.match(ticket or ""):
@@ -280,8 +279,6 @@ async def _resolve(db: AsyncSession, ticket: str, user: User
raise invalid
if not row.redeemed_by and _aware(row.expires_at) <= datetime.now(UTC):
raise invalid
- if not user.email_hash or user.email_hash != row.email_hash:
- raise HTTPException(status_code=403, detail="invite_other_account")
return row, group
@@ -312,8 +309,8 @@ async def redeem_invite_link(
db: AsyncSession = Depends(get_db),
):
"""
- Membership for the addressed account, once — and the same answer again for
- that account, because a second tab or a reload is the same person.
+ Membership for the first account that asks, once — and the same answer
+ again for that account, because a second tab or a reload is the same person.
"""
row, group = await _resolve(db, body.ticket, current_user)
if not row.redeemed_by:
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
index 7478173..6205180 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
@@ -11,15 +11,40 @@ from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
-from meshbay_hub.api.deps import get_current_user
+from meshbay_hub.api.deps import get_current_user, require_user_scope
from meshbay_hub.api.middleware import limiter
from meshbay_hub.api.netutil import client_ip
-from meshbay_hub.auth import issue_access_token
+from meshbay_hub.auth import issue_access_token, issue_mnp_token
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import GroupMember, IPLog, Node, User
router = APIRouter(prefix="/v1/nodes", tags=["nodes"])
+
+@router.post("/mnp-token")
+@limiter.limit("60/minute")
+async def mnp_token(
+ request: Request,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """Mint the short-lived token a member presents to a node in the MNP handshake.
+
+ Asked for with the member's own session token (require_user_scope, so a node
+ daemon token cannot mint one). The result carries the member's current group
+ membership and `aud=MNP_AUD`, so it authorises the member to a node and is
+ refused by the hub API. Short-lived on purpose; the client refetches it for a
+ new connection or a reconnect, and it is checked only at the handshake, so a
+ film already playing is never interrupted by its expiry.
+ """
+ rows = await db.execute(
+ select(GroupMember.group_id).where(GroupMember.user_id == current_user.id))
+ group_ids = [gid for (gid,) in rows.all()]
+ return {
+ "mnp_token": issue_mnp_token(current_user.id, groups=group_ids),
+ "expires_in": 900,
+ }
+
NODE_AUTH_TIMESTAMP_WINDOW = 60 # seconds
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index a994acb..7046c2f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -728,6 +728,14 @@ async def get_current_user_info(
class UpdateProfileRequest(BaseModel):
email: str | None = None
+ # Required to change the address on file. Changing it is the first step of
+ # an account takeover from a bare access token: the confirmation code goes
+ # to the new (attacker) address, and a verified address then unlocks the
+ # passphrase-reset path. A live access token is not enough for that — the
+ # passphrase is, exactly as for `change_password` and `delete_own_account`.
+ # This matters because a member hands its access token to every node it
+ # connects to (the MNP handshake), so a node operator holds one.
+ auth_key: str | None = None
@field_validator("email")
@classmethod
@@ -768,6 +776,22 @@ async def update_profile(
new_email = body.email.strip()
eh = hash_email_blind(new_email)
+ # Changing the address on file requires the passphrase, not merely a
+ # live token. Same second factor, and the same throttle, as a passphrase
+ # change or an account deletion — the hub still never sees the
+ # passphrase, only the derived auth_key.
+ if not body.auth_key:
+ raise HTTPException(
+ status_code=403,
+ detail="Changing your e-mail requires your passphrase.")
+ await _take_login_attempt(db, current_user.username)
+ if not await verify_password_off_loop(
+ body.auth_key, current_user.pw_hash, current_user.pw_salt,
+ current_user.pw_version):
+ raise HTTPException(status_code=403,
+ detail="Passphrase does not match")
+ await login_throttle.clear(db, current_user.username)
+
# How often one account may point the hub at a *different* address.
# Long, because this is the only path where a signed-in account chooses
# who receives a message, and a short delay alone still allows one
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index 626c639..c9cb8d5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -12,6 +12,7 @@ The root route (/) returns the SPA HTML shell.
"""
import hashlib
+import html
from pathlib import Path
from fastapi import APIRouter
@@ -142,17 +143,49 @@ CSP = "; ".join([
@router.get("/app", response_class=HTMLResponse)
async def app_root():
- return HTMLResponse(_HTML, headers=_NO_STORE)
+ return HTMLResponse(_shell, headers=_NO_STORE)
@router.get("/app/{path:path}", response_class=HTMLResponse)
async def app_catchall(path: str):
- return HTMLResponse(_HTML, headers=_NO_STORE)
+ return HTMLResponse(_shell, headers=_NO_STORE)
@router.get("/", response_class=HTMLResponse)
async def index():
- return HTMLResponse(_HTML, headers=_NO_STORE)
+ return HTMLResponse(_shell, headers=_NO_STORE)
+
+
+# What a messenger draws for a link to this hub: an invitation, or the address
+# itself. Signal and WhatsApp read these tags and nothing else, and resolve only
+# an absolute og:image, so the shell is rendered for the hub's public name —
+# `identity.id`, the name mail links already use. Short on purpose: a preview
+# shows a line or two of the description and cuts the rest. The image is the
+# square icon, which is the shape a thumbnail is cut to anyway.
+PREVIEW_DESCRIPTION = "Your files stay at home. Reach them from anywhere."
+
+
+def _preview_tags(hub_id: str) -> str:
+ origin = html.escape(f"https://{hub_id}", quote=True)
+ return f"""\
+ <meta name="description" content="{PREVIEW_DESCRIPTION}">
+ <meta property="og:type" content="website">
+ <meta property="og:site_name" content="MeshBay">
+ <meta property="og:title" content="MeshBay">
+ <meta property="og:description" content="{PREVIEW_DESCRIPTION}">
+ <meta property="og:url" content="{origin}/">
+ <meta property="og:image" content="{origin}/og-image.jpg">
+ <meta property="og:image:type" content="image/jpeg">
+ <meta property="og:image:width" content="600">
+ <meta property="og:image:height" content="600">
+ <meta property="og:image:alt" content="The MeshBay logo">
+"""
+
+
+def configure(hub_id: str) -> None:
+ """Render the shell for this hub's public name (`identity.id`)."""
+ global _shell
+ _shell = _HTML.replace("{preview}", _preview_tags(hub_id))
_HTML = """\
@@ -171,6 +204,8 @@ _HTML = """\
<meta name="viewport"
content="width=device-width, initial-scale=1, interactive-widget=resizes-content">
<title>MeshBay</title>
+{preview} <link rel="icon" href="/a/{v}/favicon.ico" sizes="16x16 32x32 48x48">
+ <link rel="apple-touch-icon" href="/a/{v}/apple-touch-icon.png">
<link rel="stylesheet" href="/a/{v}/style.css">
</head>
<body>
@@ -205,3 +240,5 @@ _HTML = """\
</body>
</html>
""".replace("{v}", ASSET_V)
+
+_shell = _HTML.replace("{preview}", _preview_tags("meshbay.org"))