aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-28 21:14:10 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-28 21:14:10 +0200
commita421a03d2be16670dc8d9076d26f4a7eac669986 (patch)
treece8a0053497278086696a8f802e97605f80903f4 /packages/meshbay-common/src/meshbay_common
parentf63104b82da24ff3f406c53346300bd50788796f (diff)
downloadmeshbay-a421a03d2be16670dc8d9076d26f4a7eac669986.tar.gz
fix: bound pending admin challenges and sign every value an op acts on
Any member could make a node hold unbounded challenge requests; a connection now keeps at most 8, 64 KiB each. root_add, group_attach, invite_create and tmdb_config signed less than they did; their subjects are now canonical JSON of every value (the TMDB token by SHA-256). MNP 5.0, floor kept at 4.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-common/src/meshbay_common')
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py13
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py43
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py2
3 files changed, 57 insertions, 1 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index 842c52d..fbfdd4d 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -249,5 +249,16 @@ __version__ = "0.16.0"
# API. A pre-4.0 client presents the session token and is refused at the
# handshake — there is no compatibility branch, because leaving one would keep
# the disclosure reachable on every node. So the floor moves with it.
-MNP_VERSION = "4.0"
+#
+# 5.0 (2026-09-28) is a MAJOR — four signed operations now sign everything they
+# do. `root_add` signed its path and not whether every member may write there;
+# `group_attach` signed a group's name and not the directory it exposes;
+# `invite_create` did not sign the name it records; `tmdb_config` did not bind
+# the token (it now names its SHA-256). Each subject is canonical JSON of every
+# value the node acts on (`adminop.structured_subject`). A 4.x client refuses to
+# sign the new subjects, and a 5.0 client the old ones — so those four fail, with
+# a refusal, across the break. The break is confined to them, so the floor stays
+# at 4.0: everything else a 4.x peer does still works, and nothing is left
+# unsigned on either side — no node accepts the old subjects.
+MNP_VERSION = "5.0"
MHP_VERSION = "0.1"
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
index c679718..4379519 100644
--- a/packages/meshbay-common/src/meshbay_common/adminop.py
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -30,6 +30,9 @@ fields it received, the node from the state it stored. They are compared by
producing the same bytes, never by trusting a value off the wire.
"""
+import hashlib
+import json
+
ADMIN_TRANSCRIPT_PREFIX = b"meshbay:admin:v1"
# Operations that require node-operator authority.
@@ -135,6 +138,46 @@ OP_GROUP_DETACH = "group_detach"
ADMIN_CHALLENGE_TTL = 120 # seconds
+def structured_subject(fields: dict) -> str:
+ """
+ The subject of an operation whose effect is more than one value.
+
+ Every value the executor acts on is in here, because the signature covers the
+ subject and nothing else of the request: a root's path alone left whether
+ every member may write there unsigned. Canonical JSON — sorted keys, no
+ whitespace, UTF-8 — so `null`, `""` and a value stay distinct, and the
+ browser's `adminSubject` (static/crypto.js) produces the same bytes.
+ """
+ return json.dumps(fields, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
+
+
+def secret_digest(value: str | None) -> str | None:
+ """A secret named in a subject without being written there: `None` (leave it
+ unchanged) and `""` (clear it) as themselves, anything else as its SHA-256."""
+ if not value:
+ return value
+ return "sha256:" + hashlib.sha256(value.encode()).hexdigest()
+
+
+def root_add_subject(path: str, name: str, kind: str, writable: bool,
+ removable: bool) -> str:
+ return structured_subject({"path": path, "name": name, "kind": kind,
+ "writable": writable, "removable": removable})
+
+
+def group_attach_subject(name: str, shared_dir: str, writable: bool) -> str:
+ return structured_subject({"name": name, "shared_dir": shared_dir,
+ "writable": writable})
+
+
+def invite_create_subject(user_id: str, username: str) -> str:
+ return structured_subject({"user_id": user_id, "username": username})
+
+
+def tmdb_config_subject(token: str | None, language: str | None) -> str:
+ return structured_subject({"token": secret_digest(token), "language": language})
+
+
def admin_transcript(
op: str,
node_pk_b64: str,
diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py
index 992fe8a..c3d5b94 100644
--- a/packages/meshbay-common/src/meshbay_common/handshake.py
+++ b/packages/meshbay-common/src/meshbay_common/handshake.py
@@ -89,6 +89,8 @@ CHALLENGE_PREFIX = b"meshbay:mnp:challenge:v1"
# hub session token (MNP_VERSION note). A pre-4.0 peer presents the session
# token, which this node now refuses — so the floor moves to 4.0 rather than
# leaving a branch that would keep a hub credential reachable by every node.
+# 5.0 (2026-09-28) does not move it: the break is confined to four signed
+# operations, which a peer across it refuses to sign (MNP_VERSION note).
MNP_MIN_SUPPORTED = "4.0"
ROLE_CLIENT = "client"