aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-common')
-rw-r--r--packages/meshbay-common/src/meshbay_common/__init__.py14
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py45
-rw-r--r--packages/meshbay-common/src/meshbay_common/groupbox.py2
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py33
-rw-r--r--packages/meshbay-common/tests/test_admin_subject_parity.py47
-rw-r--r--packages/meshbay-common/tests/test_js_python_parity.py2
6 files changed, 49 insertions, 94 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/__init__.py b/packages/meshbay-common/src/meshbay_common/__init__.py
index b68e828..2a58326 100644
--- a/packages/meshbay-common/src/meshbay_common/__init__.py
+++ b/packages/meshbay-common/src/meshbay_common/__init__.py
@@ -260,5 +260,17 @@ __version__ = "0.17.0"
# 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"
+#
+# 6.0 (2026-10-02) is a MAJOR — three signed operations are removed: `root_add`,
+# `root_update` and `group_attach`. What of the operator's disk is shared, and
+# whether members may write there, is decided on the node's own machine (the
+# desktop application over loopback, or the CLI), never
+# over the wire. A 5.x client that sends one gets no answer, as for any unknown
+# type; everything else it does still works, so the floor stays at 4.0.
+# The same version removes ten operator messages no client ever sent —
+# `gek_rotate`, `member_unpin`, `transfer_limits`, `group_detach` (signed) and
+# `node_status`, `node_settings_set`, `roster_read`, `denylist_read`,
+# `denylist_clear`, `node_reload` — whose work the Node page and the CLI do over
+# loopback.
+MNP_VERSION = "6.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 4379519..bd7a439 100644
--- a/packages/meshbay-common/src/meshbay_common/adminop.py
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -47,14 +47,6 @@ OP_INVITE_LINK_CREATE = "invite_link_create"
# Taking back an unredeemed link, by the handle it was issued with.
OP_INVITE_CANCEL = "invite_cancel"
OP_MEMBER_REVOKE = "member_revoke"
-# Rotating the group key is what actually takes it away from a revoked member:
-# revocation stops the node serving the *next* key, and they still hold the
-# current one. The node generates the new key itself with its own CSPRNG, so
-# nothing arriving over MNP contributes key material — the C5b rule is about
-# key material from outside, not about the instruction.
-OP_GEK_ROTATE = "gek_rotate"
-# Forgetting a pinned identity, so someone can pair again after losing a device.
-OP_MEMBER_UNPIN = "member_unpin"
# Which group "applications" (Chat, Files, and whatever registers later) are
# shown to members. Signed like the rest: it decides what a member sees, not
# anything about key material, but an unsigned toggle would let any member
@@ -67,11 +59,6 @@ OP_APPS_ENABLED = "apps_enabled"
# security property in itself, but the pattern (every operator setting is
# signed) is what keeps the authorization model simple to reason about.
OP_SET_SCAN_SETTINGS = "set_scan_settings"
-# How many transfers one member may run at once in this group. Signed like the
-# rest: an unsigned cap is one any member can raise for themselves, which makes
-# the control a suggestion. The subject is "d=2,u=2" so what the operator is
-# shown before signing names the outcome and not the operation.
-OP_TRANSFER_LIMITS = "transfer_limits"
# Whether the node uses the operator's own API token/language instead of the
# shipped default — node-wide (docs/MESHBAY_DESIGN.md §9.7), one credential
# shared by every group. Signed like the rest: it turns on outbound
@@ -101,7 +88,6 @@ OP_TMDB_REMATCH = "tmdb_rematch"
# the lesson was already learned once). Signed for the same reason as
# tmdb_enabled.
OP_MUSICBRAINZ_ENABLED = "musicbrainz_enabled"
-OP_ROOT_ADD = "root_add"
OP_ROOT_REMOVE = "root_remove"
# One op for every application's directories. The subject is
# "<app>:<comma-joined sorted paths>" so what the operator is shown before
@@ -115,18 +101,26 @@ OP_CHAT_LINK_PREVIEW = "chat_link_preview"
# operator's, signed like the other per-group switches.
OP_SEARCH_LISTED = "search_listed"
# Open a new chat epoch for a group, by hand. The removals that matter open one
-# by themselves (member revoke/unpin, device revoke, gek_rotate); this is the
-# operator saying "do it anyway", which is the same shape as `gek_rotate` and
-# signed for the same reason.
+# by themselves (member revoke/unpin, device revoke, group key rotation); this is the
+# operator saying "do it anyway", and is signed like the rest.
#
# There is no op for *enabling* chat encryption. It is not a setting — MNP 2.0
# has no plaintext chat to fall back to.
OP_CHAT_EPOCH = "chat_epoch"
-OP_ROOT_UPDATE = "root_update"
OP_ROOT_EJECT = "root_eject"
OP_ROOT_PLUG = "root_plug"
-OP_GROUP_ATTACH = "group_attach"
-OP_GROUP_DETACH = "group_detach"
+# Also gone with 6.0, because no client ever sent them: `gek_rotate`,
+# `member_unpin`, `transfer_limits` and `group_detach`. Rotating the group key,
+# forgetting an identity, the per-member transfer caps and no longer hosting a
+# group are done on the node's machine — the desktop application's Node page
+# or the CLI. A door nobody uses is an untested way in.
+# OP_ROOT_ADD, OP_ROOT_UPDATE and OP_GROUP_ATTACH are gone (MNP 6.0). Each one
+# chose what of the operator's disk is shared and who may write there, and a
+# signature proves only that the operator's key signed — in a browser, through
+# code the hub serves; on the desktop, through a renderer that parses content
+# from nodes. Sharing a folder, hosting a group and opening a folder to writes
+# are done on the node's own machine (loopback) or with
+# the CLI, and a message that does not exist cannot be mis-authorized.
# OP_GEK_BUNDLE_STORE is gone. Members no longer hand the node key material at
# all: the node holds the GEK and wraps it itself, for a key the recipient proved
# they hold (see `join.py` and docs/MESHBAY_DESIGN.md §3.4). The operation existed
@@ -159,17 +153,6 @@ def secret_digest(value: str | None) -> str | None:
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})
diff --git a/packages/meshbay-common/src/meshbay_common/groupbox.py b/packages/meshbay-common/src/meshbay_common/groupbox.py
index 260e362..ae79fcc 100644
--- a/packages/meshbay-common/src/meshbay_common/groupbox.py
+++ b/packages/meshbay-common/src/meshbay_common/groupbox.py
@@ -74,7 +74,7 @@ _INFO = {
# the bound that matters is birthday collision under `PURPOSE_UPLOAD` — the only
# purpose with real volume, one message per 48 KiB chunk. 2**32 chunks is 200 TB
# uploaded under a single GEK before the collision probability reaches 2**-32,
-# and `gek_rotate` exists. Deriving the nonce from the payload instead would be
+# and the group key can be rotated. Deriving the nonce from the payload instead would be
# worse, not better: two chunks of identical bytes are ordinary in a file.
NONCE_LEN = 12 # 96-bit, the WebCrypto AES-GCM standard
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index af2d93b..f8e56ec 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -154,12 +154,8 @@ class MNP:
INVITE_CANCEL = "invite_cancel" # operator → node: take back an unredeemed link
MEMBER_REVOKE = "member_revoke" # operator → node: stop serving the key
MEMBER_REVOKE_ACK = "member_revoke_ack"
- MEMBER_UNPIN = "member_unpin" # operator → node: forget an identity
- MEMBER_UNPIN_ACK = "member_unpin_ack"
APPS_ENABLED = "apps_enabled" # operator → node: which group apps to show
APPS_ENABLED_ACK = "apps_enabled_ack"
- TRANSFER_LIMITS = "transfer_limits" # operator → node: per-member caps here
- TRANSFER_LIMITS_ACK = "transfer_limits_ack" # node → this group: the new caps
SET_SCAN_SETTINGS = "set_scan_settings" # operator → node: reconcile/debounce timing
SET_SCAN_SETTINGS_ACK = "set_scan_settings_ack"
MEDIA_META_REQ = "media_meta_req" # client → node: TMDB metadata for a path
@@ -216,16 +212,8 @@ class MNP:
# where it was.
DEVICE_HELLO = "device_hello" # device → node: this is me
DEVICE_HELLO_ACK = "device_hello_ack"
- # Rotation is the half of revocation that revocation cannot do: the node
- # generates a fresh key itself, so no key material crosses the wire.
- GEK_ROTATE = "gek_rotate" # operator → node: new group key
- GEK_ROTATE_ACK = "gek_rotate_ack"
INVITE_RESULT = "invite_result" # node → operator: the code, once
INVITE_LINK_RESULT = "invite_link_result" # node → operator: link code + handle, once
- NODE_STATUS = "node_status" # operator → node: list all groups + roots
- NODE_STATUS_ACK = "node_status_ack" # node → operator: full status
- ROOT_ADD = "root_add" # operator → node: add a directory to a group
- ROOT_ADD_ACK = "root_add_ack" # node → operator: confirmed
ROOT_REMOVE = "root_remove" # operator → node: remove a root from a group
ROOT_REMOVE_ACK = "root_remove_ack" # node → operator: confirmed
# One message for every application's directories, keyed by the app's own
@@ -256,34 +244,17 @@ class MNP:
# key without having to reconnect.
CHAT_EPOCH = "chat_epoch"
CHAT_EPOCH_ACK = "chat_epoch_ack"
- ROOT_UPDATE = "root_update" # operator → node: a root's flags
- ROOT_UPDATE_ACK = "root_update_ack"
ROOT_EJECT = "root_eject" # operator → node: mark removable root as ejected
ROOT_EJECT_ACK = "root_eject_ack"
ROOT_PLUG = "root_plug" # operator → node: re-enable an ejected root
ROOT_PLUG_ACK = "root_plug_ack"
# Member → node: who is in this group and which device keys they hold, with
- # the countersignature that admitted each one. Distinct from ROSTER_READ
- # below, which is the operator's view of the whole node: this is scoped to
- # one group and answers any member of it, because the point is that a member
+ # the countersignature that admitted each one. Scoped to one group and
+ # answers any member of it, because the point is that a member
# verifies another member's device *for themselves* rather than trusting the
# node's `sender_id` (Tier 2, docs/MESHBAY_DESIGN.md §3.3).
GROUP_ROSTER_REQ = "group_roster_req"
GROUP_ROSTER_RESP = "group_roster_resp"
- ROSTER_READ = "roster_read" # operator → node: identities + members
- ROSTER_READ_ACK = "roster_read_ack"
- DENYLIST_READ = "denylist_read" # operator → node: show denylist entries
- DENYLIST_READ_ACK = "denylist_read_ack"
- DENYLIST_CLEAR = "denylist_clear" # operator → node: remove denylist entry(ies)
- DENYLIST_CLEAR_ACK = "denylist_clear_ack"
- GROUP_ATTACH = "group_attach" # operator → node: host a new group
- GROUP_ATTACH_ACK = "group_attach_ack"
- GROUP_DETACH = "group_detach" # operator → node: stop hosting a group
- GROUP_DETACH_ACK = "group_detach_ack"
- NODE_SETTINGS_SET = "node_settings_set" # operator → node: change daemon settings
- NODE_SETTINGS_SET_ACK = "node_settings_set_ack"
- NODE_RELOAD = "node_reload" # operator → node: re-read node.toml
- NODE_RELOAD_ACK = "node_reload_ack"
# ── Index entry ───────────────────────────────────────────────────────────────
diff --git a/packages/meshbay-common/tests/test_admin_subject_parity.py b/packages/meshbay-common/tests/test_admin_subject_parity.py
index 7a229a9..59deaab 100644
--- a/packages/meshbay-common/tests/test_admin_subject_parity.py
+++ b/packages/meshbay-common/tests/test_admin_subject_parity.py
@@ -18,9 +18,7 @@ from pathlib import Path
import pytest
from meshbay_common.adminop import (
- group_attach_subject,
invite_create_subject,
- root_add_subject,
secret_digest,
structured_subject,
tmdb_config_subject,
@@ -34,16 +32,13 @@ pytestmark = pytest.mark.skipif(
reason="node or crypto.js unavailable — parity cannot be checked",
)
-ROOT_ADD = [
- ("/srv/Films", "", "generic", False, False),
- ("/srv/Films", "Films", "video", True, True),
- ("C:\\Users\\me\\Share", "Partagé", "photo", True, False),
- ('/srv/a "quoted", odd:name|x', "名前", "audio", False, True),
- ("/srv/tab\there\nnewline\x01ctl", "é", "generic", True, False),
-]
-GROUP_ATTACH = [
- ("photos", "/srv/photos", True),
- ("famille-été", "/mnt/disque externe/Photos", False),
+# The canonical JSON itself, on the values that are hard to get identical:
+# separators, quotes, control characters, non-ASCII, and null/""/false.
+STRUCTURED = [
+ {"path": "/srv/Films", "name": "", "writable": False, "n": None},
+ {"path": "C:\\Users\\me\\Share", "name": "Partagé", "kind": "photo"},
+ {"path": '/srv/a "quoted", odd:name|x', "name": "名前", "removable": True},
+ {"path": "/srv/tab\there\nnewline\x01ctl", "name": "é", "z": "", "a": 0},
]
INVITE_CREATE = [
("0f8fad5b-d9cb-469f-a165-70867728950e", ""),
@@ -59,13 +54,12 @@ _HARNESS = r"""
const fs = require('fs');
globalThis.window = {};
const src = fs.readFileSync(process.argv[2], 'utf8');
-const M = new Function(src + '\nreturn { rootAddSubject, groupAttachSubject, '
+const M = new Function(src + '\nreturn { adminSubject, '
+ 'inviteCreateSubject, tmdbConfigSubject };')();
const v = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
(async () => {
const out = {
- root_add: v.root_add.map((a) => M.rootAddSubject(...a)),
- group_attach: v.group_attach.map((a) => M.groupAttachSubject(...a)),
+ structured: v.structured.map((f) => M.adminSubject(f)),
invite_create: v.invite_create.map((a) => M.inviteCreateSubject(...a)),
tmdb_config: [],
};
@@ -80,7 +74,7 @@ def js(tmp_path_factory):
d = tmp_path_factory.mktemp("subject-parity")
(d / "harness.js").write_text(_HARNESS, encoding="utf-8")
(d / "vectors.json").write_text(json.dumps({
- "root_add": ROOT_ADD, "group_attach": GROUP_ATTACH,
+ "structured": STRUCTURED,
"invite_create": INVITE_CREATE, "tmdb_config": TMDB_CONFIG,
}), encoding="utf-8")
proc = subprocess.run(
@@ -95,14 +89,9 @@ def _bytes(s: str) -> bytes:
return s.encode("utf-8")
-@pytest.mark.parametrize("i,args", list(enumerate(ROOT_ADD)))
-def test_root_add_subject_parity(i, args, js):
- assert _bytes(js["root_add"][i]) == _bytes(root_add_subject(*args))
-
-
-@pytest.mark.parametrize("i,args", list(enumerate(GROUP_ATTACH)))
-def test_group_attach_subject_parity(i, args, js):
- assert _bytes(js["group_attach"][i]) == _bytes(group_attach_subject(*args))
+@pytest.mark.parametrize("i,fields", list(enumerate(STRUCTURED)))
+def test_structured_subject_parity(i, fields, js):
+ assert _bytes(js["structured"][i]) == _bytes(structured_subject(fields))
@pytest.mark.parametrize("i,args", list(enumerate(INVITE_CREATE)))
@@ -116,13 +105,13 @@ def test_tmdb_config_subject_parity(i, args, js):
def test_every_value_changes_the_subject():
- base = ("/srv/Films", "Films", "video", False, False)
- variants = {root_add_subject(*base)}
- for i, other in enumerate(("/srv/Other", "Other", "audio", True, True)):
+ base = ("0f8fad5b-d9cb-469f-a165-70867728950e", "Someone")
+ variants = {invite_create_subject(*base)}
+ for i, other in enumerate(("6a2f41a3-c54c-fce8-32d2-0324e1c32e22", "Somebody")):
args = list(base)
args[i] = other
- variants.add(root_add_subject(*args))
- assert len(variants) == 6
+ variants.add(invite_create_subject(*args))
+ assert len(variants) == 3
def test_unchanged_cleared_and_set_are_three_subjects():
diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py
index f75d60a..bb52742 100644
--- a/packages/meshbay-common/tests/test_js_python_parity.py
+++ b/packages/meshbay-common/tests/test_js_python_parity.py
@@ -668,7 +668,7 @@ _KIND_FIELDS = {
"chat": {"groupId": "g" * 32, "epoch": 4,
"nonce": base64.b64encode(b"\x01" * 12).decode(),
"ct": base64.b64encode(b"ciphertext").decode()},
- "admin": {"op": "root_add", "nodePk": "Tk9ERVBL", "groupId": "g" * 32,
+ "admin": {"op": "root_remove", "nodePk": "Tk9ERVBL", "groupId": "g" * 32,
"subject": '{"path":"/café"}', "nonce": _NONCE, "ts": 1_700_000_000},
}