aboutsummaryrefslogtreecommitdiffstats
path: root/packages
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
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')
-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
-rw-r--r--packages/meshbay-common/tests/test_admin_subject_parity.py145
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js40
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js16
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-media.js15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js6
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py27
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py19
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py13
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py31
-rw-r--r--packages/meshbay-node/tests/golden/dispatch.json128
-rw-r--r--packages/meshbay-node/tests/test_admin_challenge_bounds.py135
-rw-r--r--packages/meshbay-node/tests/test_tmdb_config_policy.py13
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py4
16 files changed, 540 insertions, 110 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"
diff --git a/packages/meshbay-common/tests/test_admin_subject_parity.py b/packages/meshbay-common/tests/test_admin_subject_parity.py
new file mode 100644
index 0000000..7a229a9
--- /dev/null
+++ b/packages/meshbay-common/tests/test_admin_subject_parity.py
@@ -0,0 +1,145 @@
+"""
+The subjects of multi-value admin operations are byte-identical in the browser and
+in Python.
+
+The subject is what the operator's signature covers of a request, and each side
+builds it on its own — the node from the request it stored, the client from what
+the person asked for. A one-byte disagreement does not weaken anything (the client
+refuses to sign), but it makes the operation impossible from a browser, and nothing
+else in the suite crosses this boundary.
+
+Skipped when node is unavailable; that is a coverage gap, not a pass.
+"""
+
+import json
+import shutil
+import subprocess
+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,
+)
+
+CRYPTO_JS = (Path(__file__).resolve().parents[2]
+ / "meshbay-hub" / "src" / "meshbay_hub" / "static" / "crypto.js")
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not CRYPTO_JS.exists(),
+ 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),
+]
+INVITE_CREATE = [
+ ("0f8fad5b-d9cb-469f-a165-70867728950e", ""),
+ ("0f8fad5b-d9cb-469f-a165-70867728950e", "Élodie \"E\" 🙂"),
+]
+TMDB_CONFIG = [
+ (None, None), ("", None), (None, ""), ("", ""),
+ ("eyJhbGciOiJIUzI1NiJ9.token", "fr-FR"),
+ ("abc", "keep"),
+]
+
+_HARNESS = r"""
+const fs = require('fs');
+globalThis.window = {};
+const src = fs.readFileSync(process.argv[2], 'utf8');
+const M = new Function(src + '\nreturn { rootAddSubject, groupAttachSubject, '
+ + '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)),
+ invite_create: v.invite_create.map((a) => M.inviteCreateSubject(...a)),
+ tmdb_config: [],
+ };
+ for (const a of v.tmdb_config) out.tmdb_config.push(await M.tmdbConfigSubject(...a));
+ process.stdout.write(JSON.stringify(out));
+})();
+"""
+
+
+@pytest.fixture(scope="module")
+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,
+ "invite_create": INVITE_CREATE, "tmdb_config": TMDB_CONFIG,
+ }), encoding="utf-8")
+ proc = subprocess.run(
+ ["node", str(d / "harness.js"), str(CRYPTO_JS), str(d / "vectors.json")],
+ capture_output=True, text=True, encoding="utf-8", timeout=60)
+ if proc.returncode != 0:
+ pytest.fail(f"node harness failed:\n{proc.stderr}")
+ return json.loads(proc.stdout)
+
+
+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,args", list(enumerate(INVITE_CREATE)))
+def test_invite_create_subject_parity(i, args, js):
+ assert _bytes(js["invite_create"][i]) == _bytes(invite_create_subject(*args))
+
+
+@pytest.mark.parametrize("i,args", list(enumerate(TMDB_CONFIG)))
+def test_tmdb_config_subject_parity(i, args, js):
+ assert _bytes(js["tmdb_config"][i]) == _bytes(tmdb_config_subject(*args))
+
+
+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)):
+ args = list(base)
+ args[i] = other
+ variants.add(root_add_subject(*args))
+ assert len(variants) == 6
+
+
+def test_unchanged_cleared_and_set_are_three_subjects():
+ assert len({tmdb_config_subject(None, None), tmdb_config_subject("", None),
+ tmdb_config_subject("t", None)}) == 3
+ assert len({tmdb_config_subject(None, None), tmdb_config_subject(None, ""),
+ tmdb_config_subject(None, "fr-FR")}) == 3
+
+
+def test_the_token_is_never_written_into_the_subject():
+ token = "eyJhbGciOiJIUzI1NiJ9.a-real-looking-secret"
+ assert token not in tmdb_config_subject(token, "fr-FR")
+ assert secret_digest(token).startswith("sha256:")
+
+
+def test_a_crafted_field_cannot_impersonate_another():
+ # Under a naive "path|name" join these two would collide.
+ a = structured_subject({"path": "/a|name=b", "name": ""})
+ b = structured_subject({"path": "/a", "name": "b"})
+ assert a != b
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index b74732c..0ca8ee5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -307,6 +307,43 @@ function b64encode(bytes) {
return btoa(String.fromCharCode(...bytes));
}
+// ── Admin operation subjects ────────────────────────────────────────────────
+// Mirrors meshbay_common/adminop.py. The subject is what the signature covers of
+// a request, so an operation whose effect is several values names them all.
+// Canonical JSON — sorted keys, no whitespace — so both sides build the same
+// bytes, and `null`, `""` and a value stay distinct.
+
+function adminSubject(fields) {
+ const sorted = {};
+ for (const k of Object.keys(fields).sort()) sorted[k] = fields[k];
+ return JSON.stringify(sorted);
+}
+
+// A secret named without being written: null (unchanged) and '' (clear) as
+// themselves, anything else as its SHA-256.
+async function secretDigest(value) {
+ if (!value) return value;
+ const d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
+ return 'sha256:' + Array.from(new Uint8Array(d))
+ .map((b) => b.toString(16).padStart(2, '0')).join('');
+}
+
+function rootAddSubject(path, name, kind, writable, removable) {
+ return adminSubject({ path, name, kind, writable, removable });
+}
+
+function groupAttachSubject(name, sharedDir, writable) {
+ return adminSubject({ name, shared_dir: sharedDir, writable });
+}
+
+function inviteCreateSubject(userId, username) {
+ return adminSubject({ user_id: userId, username });
+}
+
+async function tmdbConfigSubject(token, language) {
+ return adminSubject({ token: await secretDigest(token), language });
+}
+
// ── Admin operation transcript ───────────────────────────────────────────────
// Mirrors meshbay_common/adminop.py::admin_transcript(). Both sides build these
// bytes independently; they are never taken off the wire.
@@ -535,7 +572,8 @@ window.MeshBayCrypto = {
importGEK, deriveChunkKey, decryptChunkBin,
openGroup, sealGroup,
unwrapGEK, b64encode, b64decode,
- adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding,
+ adminTranscript, adminSubject, rootAddSubject, groupAttachSubject,
+ inviteCreateSubject, tmdbConfigSubject, handshakeTranscript, handshakeProof, webrtcBinding,
challengeTranscript, joinTranscript, verifyNodeSignature, constantTimeEqual,
deviceRequestTranscript, deviceAddTranscript, deviceHelloTranscript,
deviceCodeHash,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
index 03a0f33..c7c3f47 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
@@ -263,7 +263,10 @@ extendTransport(class {
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'root_add', path, signFn);
+ // Everything the node will act on is in the subject, `writable` included.
+ const subject = window.MeshBayCrypto.rootAddSubject(
+ path, name || '', kind || 'generic', !!writable, !!removable);
+ return this._authorizeAdminOp(msg, 'root_add', subject, signFn);
}
return msg;
}
@@ -369,11 +372,13 @@ extendTransport(class {
async attachGroup(name, sharedDir, uploadDir, signFn) {
const msg = await this._sendAndWait({
type: 'group_attach', v: '0.1',
- name, shared_dir: sharedDir, upload_dir: uploadDir || '',
+ name, shared_dir: sharedDir, upload_dir: uploadDir || '', writable: true,
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'group_attach', name, signFn);
+ // The directory being exposed is signed, not only the group's name.
+ const subject = window.MeshBayCrypto.groupAttachSubject(name, sharedDir, true);
+ return this._authorizeAdminOp(msg, 'group_attach', subject, signFn);
}
return msg;
}
@@ -416,7 +421,10 @@ extendTransport(class {
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
- return this._authorizeAdminOp(msg, 'invite_create', userId, signFn);
+ // The node keeps the first 64 code points of the name, as Python slices.
+ const name = Array.from(username || '').slice(0, 64).join('');
+ const subject = window.MeshBayCrypto.inviteCreateSubject(userId, name);
+ return this._authorizeAdminOp(msg, 'invite_create', subject, signFn);
}
return msg;
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js
index f94eb40..cf53c08 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-media.js
@@ -106,18 +106,17 @@ extendTransport(class {
* `language`, to leave whatever is stored unchanged.
*/
async setTmdbConfig(token, language, signFn) {
+ const tok = token === undefined ? null : token;
+ const lang = language === undefined ? null : language;
const msg = await this._sendAndWait({
- type: 'tmdb_config', v: '0.7',
- token: token === undefined ? null : token,
- language: language === undefined ? null : language,
+ type: 'tmdb_config', v: '0.7', token: tok, language: lang,
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
- // Must match the node's subject byte-for-byte (apps/video_meta.py
- // _do_tmdb_config) — the token itself is never part of the subject
- // (it would end up in the audit log in plaintext), only whether one
- // was supplied. The language is not a secret, so it appears as-is.
- const subject = `custom_token=${token ? 'yes' : 'no'},language=${language || 'default'}`;
+ // Must match the node's subject byte for byte (apps/video_meta.py
+ // _do_tmdb_config). The token is named by its SHA-256, never written:
+ // the subject ends up in the audit log.
+ const subject = await window.MeshBayCrypto.tmdbConfigSubject(tok, lang);
return this._authorizeAdminOp(msg, 'tmdb_config', subject, signFn);
}
return msg;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 0f3f3b8..7f1b232 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -305,8 +305,10 @@ window.addEventListener('hashchange', () => {
// The `v: '0.1'` on every other message in this file is the historical value
// and is read by nothing; it is left alone deliberately. The range is
// negotiated once, at the start, not restated per message.
-const MNP_V = '4.0';
-// Raised with it: 4.0 is a flag day. A member now presents a short-lived
+const MNP_V = '5.0';
+// Not raised with 5.0 (see meshbay_common/__init__.py): the break is confined to
+// four signed operations, which a peer on the other side of it refuses to sign.
+// Set at 4.0, a flag day. A member now presents a short-lived
// MNP-audience token in the handshake, not its hub session token — a node
// older than 4.0 expected the session token, and one newer refuses it, so the
// two cannot authenticate across the break. This is the C6 rule: no
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py
index f42d8e8..daaee62 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admin.py
@@ -5,6 +5,7 @@ import base64
import os
import time
+import msgpack
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from meshbay_common import MNP_VERSION
from meshbay_common.adminop import (
@@ -42,6 +43,16 @@ from meshbay_common.adminop import (
from meshbay_common.crypto import pk_to_b64
from meshbay_common.protocol import MNP
+# What one connection may have waiting for a signature. Anyone authenticated can
+# ask for a challenge — the signature is what is checked, and it comes later — so
+# without a bound a member who never answers makes the node keep every request,
+# payload and all, for the life of the connection (§13.5b). A person signs one
+# operation at a time; a handful covers a settings page saving several at once.
+MAX_PENDING_ADMIN_OPS = 8
+# The subject and payload of one pending operation, packed. A path is at most a
+# few KiB, and the largest field a legitimate request carries is a directory list.
+MAX_ADMIN_OP_BYTES = 64 * 1024
+
# Which executor runs each signed operation once its signature has been
# checked. Every one runs as a task of the session.
_ADMIN_EXECUTORS = {
@@ -98,8 +109,22 @@ class AdminMixin:
(e.g. root management from a NodePage connection).
"""
gid = group_id if group_id is not None else (self._group_id or "")
+ now = time.time()
+ for op_id, pending in list(self._admin_ops.items()):
+ if now - pending["ts"] > ADMIN_CHALLENGE_TTL:
+ del self._admin_ops[op_id]
+ if len(self._admin_ops) >= MAX_PENDING_ADMIN_OPS:
+ self._send({"type": "error", "detail": "Too many operations waiting for a "
+ "signature", "code": "too_many_pending"})
+ self._audit("admin_pending_flood", op)
+ return
+ if len(msgpack.packb([subject, payload or {}], use_bin_type=True)) \
+ > MAX_ADMIN_OP_BYTES:
+ self._send({"type": "error", "detail": "Request too large",
+ "code": "too_large"})
+ return
nonce = os.urandom(32)
- ts = int(time.time())
+ ts = int(now)
op_id = base64.b64encode(os.urandom(16)).decode()
self._admin_ops[op_id] = {
"op": op, "subject": subject, "nonce": nonce, "ts": ts,
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py
index b02e59a..e678a13 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/admission.py
@@ -8,7 +8,12 @@ import time
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from meshbay_common import MNP_VERSION
-from meshbay_common.adminop import OP_INVITE_CANCEL, OP_INVITE_CREATE, OP_INVITE_LINK_CREATE
+from meshbay_common.adminop import (
+ OP_INVITE_CANCEL,
+ OP_INVITE_CREATE,
+ OP_INVITE_LINK_CREATE,
+ invite_create_subject,
+)
from meshbay_common.crypto import wrap_gek_aes
from meshbay_common.device import (
DEVICE_TTL,
@@ -71,11 +76,13 @@ class AdmissionMixin:
})
return
- self._issue_admin_challenge(OP_INVITE_CREATE, invitee_id, {
- "group_id": group_id,
- "user_id": invitee_id,
- "username": str(msg.get("username", ""))[:64],
- })
+ username = str(msg.get("username", ""))[:64]
+ self._issue_admin_challenge(
+ OP_INVITE_CREATE, invite_create_subject(invitee_id, username), {
+ "group_id": group_id,
+ "user_id": invitee_id,
+ "username": username,
+ })
def _do_invite_link_create(self, msg: dict) -> None:
"""
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py
index 834bac4..a9b35dc 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/apps/video_meta.py
@@ -11,6 +11,7 @@ from meshbay_common.adminop import (
OP_TMDB_ENABLED,
OP_TMDB_OVERRIDE,
OP_TMDB_REMATCH,
+ tmdb_config_subject,
)
from meshbay_common.protocol import MNP
@@ -60,12 +61,12 @@ class VideoMetaMixin:
if not self._has_admin_authority():
self._send({"type": "error", "detail": "No authorized key for this"})
return
- # The subject is the signed, audited, human-shown string — it must
- # never contain the token itself (it would end up in the audit log
- # in plaintext). The actual token travels only in `payload`, which
- # is node-side context, never re-sent or re-verified from the wire.
- # The language is not a secret, so it travels in the subject itself.
- subject = f"custom_token={'yes' if token else 'no'},language={language or 'default'}"
+ # The subject is the signed, audited string, so it must never contain
+ # the token itself (it would end up in the audit log in plaintext); it
+ # carries the token's SHA-256 instead, which binds the signature to this
+ # token without writing it down. `None` (unchanged) and `""` (clear)
+ # stay distinct, for the token and the language alike.
+ subject = tmdb_config_subject(token, language)
self._issue_admin_challenge(
OP_TMDB_CONFIG, subject,
payload={"token": token, "language": language},
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py
index 9d58d8c..f7bbbfa 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/node_ops.py
@@ -14,6 +14,8 @@ from meshbay_common.adminop import (
OP_ROOT_UPDATE,
OP_SET_SCAN_SETTINGS,
OP_TRANSFER_LIMITS,
+ group_attach_subject,
+ root_add_subject,
)
from meshbay_common.protocol import MNP
@@ -246,10 +248,11 @@ class NodeOpsMixin:
# is ignored rather than obeyed: on load it forces every other root
# read-only, which is the model the RO/RW one replaced. A second
# writable directory is `root_add` with `writable`.
+ writable = bool(msg.get("writable", True))
+ # The directory being exposed is signed, not only the group's name.
self._issue_admin_challenge(
- OP_GROUP_ATTACH, name,
- payload={"name": name, "shared_dir": shared_dir,
- "writable": bool(msg.get("writable", True))},
+ OP_GROUP_ATTACH, group_attach_subject(name, shared_dir, writable),
+ payload={"name": name, "shared_dir": shared_dir, "writable": writable},
group_id="")
async def _admin_exec_group_attach(
@@ -342,16 +345,20 @@ class NodeOpsMixin:
if not self._has_admin_authority():
self._send({"type": "error", "detail": "No authorized key for this"})
return
+ payload = {
+ "group_id": target_group, "path": path,
+ "name": str(msg.get("name", ""))[:128],
+ "kind": str(msg.get("kind", "generic"))[:16],
+ "writable": bool(msg.get("writable", msg.get("upload", False))),
+ "removable": bool(msg.get("removable", False)),
+ }
+ # Everything the executor acts on is signed — `writable` decides whether
+ # every member may write there. The group is in the transcript itself.
self._issue_admin_challenge(
- OP_ROOT_ADD, path,
- payload={
- "group_id": target_group, "path": path,
- "name": str(msg.get("name", ""))[:128],
- "kind": str(msg.get("kind", "generic"))[:16],
- "writable": bool(msg.get("writable", msg.get("upload", False))),
- "removable": bool(msg.get("removable", False)),
- },
- group_id=target_group)
+ OP_ROOT_ADD,
+ root_add_subject(path, payload["name"], payload["kind"],
+ payload["writable"], payload["removable"]),
+ payload=payload, group_id=target_group)
async def _admin_exec_root_add(
self, pending: dict, transcript: bytes, sig: bytes,
diff --git a/packages/meshbay-node/tests/golden/dispatch.json b/packages/meshbay-node/tests/golden/dispatch.json
index 4767996..a7bb543 100644
--- a/packages/meshbay-node/tests/golden/dispatch.json
+++ b/packages/meshbay-node/tests/golden/dispatch.json
@@ -2068,7 +2068,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -2391,7 +2391,7 @@
"subject": "gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -2410,7 +2410,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -2429,7 +2429,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -2448,7 +2448,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -2718,7 +2718,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -2732,7 +2732,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -2746,7 +2746,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -2760,7 +2760,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -2774,7 +2774,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -2788,7 +2788,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -2802,7 +2802,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -2816,7 +2816,7 @@
"messages": [],
"req_id": 4242,
"type": "chat_hist_resp",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -8535,7 +8535,7 @@
"subject": "gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -8554,7 +8554,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -8573,7 +8573,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -8592,7 +8592,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -8924,10 +8924,10 @@
"op": "group_attach",
"op_id": "<volatile>",
"req_id": 4242,
- "subject": "['x']",
+ "subject": "{\"name\":\"['x']\",\"shared_dir\":\"['x']\",\"writable\":true}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -8943,10 +8943,10 @@
"op": "group_attach",
"op_id": "<volatile>",
"req_id": 4242,
- "subject": "7",
+ "subject": "{\"name\":\"7\",\"shared_dir\":\"7\",\"writable\":true}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -8962,10 +8962,10 @@
"op": "group_attach",
"op_id": "<volatile>",
"req_id": 4242,
- "subject": "x",
+ "subject": "{\"name\":\"x\",\"shared_dir\":\"x\",\"writable\":true}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -9300,7 +9300,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -9319,7 +9319,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -9338,7 +9338,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -11641,7 +11641,7 @@
"subject": "link:gggggggggggggggggggggggggggggggg",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -13740,7 +13740,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -13759,7 +13759,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -13778,7 +13778,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -14113,7 +14113,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -14132,7 +14132,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -14151,7 +14151,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -16212,7 +16212,7 @@
"req_id": 4242,
"token": null,
"type": "pong",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -16227,7 +16227,7 @@
"x"
],
"type": "pong",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -16240,7 +16240,7 @@
"req_id": 4242,
"token": 7,
"type": "pong",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -16253,7 +16253,7 @@
"req_id": 4242,
"token": "x",
"type": "pong",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -16266,7 +16266,7 @@
"req_id": 4242,
"token": null,
"type": "pong",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -16281,7 +16281,7 @@
"x"
],
"type": "pong",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -16294,7 +16294,7 @@
"req_id": 4242,
"token": 7,
"type": "pong",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -16307,7 +16307,7 @@
"req_id": 4242,
"token": "x",
"type": "pong",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -16639,10 +16639,10 @@
"op": "root_add",
"op_id": "<volatile>",
"req_id": 4242,
- "subject": "['x']",
+ "subject": "{\"kind\":\"['x']\",\"name\":\"['x']\",\"path\":\"['x']\",\"removable\":true,\"writable\":true}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -16658,10 +16658,10 @@
"op": "root_add",
"op_id": "<volatile>",
"req_id": 4242,
- "subject": "7",
+ "subject": "{\"kind\":\"7\",\"name\":\"7\",\"path\":\"7\",\"removable\":true,\"writable\":true}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -16677,10 +16677,10 @@
"op": "root_add",
"op_id": "<volatile>",
"req_id": 4242,
- "subject": "x",
+ "subject": "{\"kind\":\"x\",\"name\":\"x\",\"path\":\"x\",\"removable\":true,\"writable\":true}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -17015,7 +17015,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -17034,7 +17034,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -17053,7 +17053,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -17388,7 +17388,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -17407,7 +17407,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -17426,7 +17426,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -17761,7 +17761,7 @@
"subject": "['x']",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -17780,7 +17780,7 @@
"subject": "7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -17799,7 +17799,7 @@
"subject": "x",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -18134,7 +18134,7 @@
"subject": "['x']:rw=on,rem=on",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -18153,7 +18153,7 @@
"subject": "7:rw=on,rem=on",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -18172,7 +18172,7 @@
"subject": "x:rw=on,rem=on",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -21116,10 +21116,10 @@
"op": "tmdb_config",
"op_id": "<volatile>",
"req_id": 4242,
- "subject": "custom_token=no,language=default",
+ "subject": "{\"language\":null,\"token\":null}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -21159,10 +21159,10 @@
"op": "tmdb_config",
"op_id": "<volatile>",
"req_id": 4242,
- "subject": "custom_token=yes,language=x",
+ "subject": "{\"language\":\"x\",\"token\":\"sha256:2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881\"}",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
@@ -23049,7 +23049,7 @@
"subject": "d=7,u=7",
"ts": "<volatile>",
"type": "admin_challenge",
- "v": "4.0"
+ "v": "5.0"
}
],
"spawned": []
diff --git a/packages/meshbay-node/tests/test_admin_challenge_bounds.py b/packages/meshbay-node/tests/test_admin_challenge_bounds.py
new file mode 100644
index 0000000..fd3b2f1
--- /dev/null
+++ b/packages/meshbay-node/tests/test_admin_challenge_bounds.py
@@ -0,0 +1,135 @@
+"""
+What a connection may leave waiting for a signature (docs/MESHBAY_DESIGN.md §13.5b).
+
+Anyone authenticated can ask for an admin challenge — the signature is checked
+later — so a member who never answers must not make the node keep every request.
+Measured before the bound: 200 `root_add` of 1 MiB each from a plain member held
+200 pending operations and ~400 MiB for the life of the connection.
+"""
+
+import struct
+import time
+
+import msgpack
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_node.transport.webrtc.admin import MAX_ADMIN_OP_BYTES, MAX_PENDING_ADMIN_OPS
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+GROUP = "g" * 32
+
+
+class _Channel:
+ readyState = "open"
+
+ def __init__(self):
+ self.sent = []
+
+ def send(self, data: bytes) -> None:
+ (n,) = struct.unpack(">I", data[:4])
+ self.sent.append(msgpack.unpackb(data[4:4 + n], raw=False))
+
+
+class _PC:
+ connectionState = "connected"
+ iceConnectionState = "connected"
+ remoteDescription = None
+ localDescription = None
+ sctp = None
+
+
+def _member_session():
+ """An authenticated member — not the operator — on a node that has one."""
+ ctx = {"sk_node": Ed25519PrivateKey.from_private_bytes(b"\x01" * 32),
+ "groups": {GROUP: {}}, "has_admin_authority": True}
+ s = WebRTCPeerSession(_PC(), ctx, peer_id="peer")
+ s._channel = _Channel()
+ s._audit = lambda *a, **k: None
+ s._user_id, s._group_id = "member-1", GROUP
+ return s
+
+
+def _root_add(s, path: str) -> dict:
+ s._dispatch_message({"type": "root_add", "group_id": GROUP, "path": path})
+ return s._channel.sent[-1]
+
+
+def test_a_member_cannot_pile_up_challenges():
+ s = _member_session()
+ for i in range(MAX_PENDING_ADMIN_OPS):
+ assert _root_add(s, f"/srv/{i}")["type"] == "admin_challenge"
+ refused = _root_add(s, "/srv/one-too-many")
+ assert refused["type"] == "error" and refused["code"] == "too_many_pending"
+ assert len(s._admin_ops) == MAX_PENDING_ADMIN_OPS
+
+
+def test_an_oversized_request_is_not_kept():
+ s = _member_session()
+ refused = _root_add(s, "x" * (MAX_ADMIN_OP_BYTES + 1))
+ assert refused["type"] == "error" and refused["code"] == "too_large"
+ assert s._admin_ops == {}
+
+
+def test_an_expired_challenge_frees_its_place():
+ s = _member_session()
+ for i in range(MAX_PENDING_ADMIN_OPS):
+ _root_add(s, f"/srv/{i}")
+ for pending in s._admin_ops.values():
+ pending["ts"] -= 10_000
+ assert _root_add(s, "/srv/after-expiry")["type"] == "admin_challenge"
+ assert len(s._admin_ops) == 1
+
+
+def test_answering_a_challenge_frees_its_place():
+ s = _member_session()
+ for i in range(MAX_PENDING_ADMIN_OPS):
+ _root_add(s, f"/srv/{i}")
+ op_id = next(iter(s._admin_ops))
+ s._dispatch_message({"type": "admin_response", "op_id": op_id, "signature": "!!"})
+ assert len(s._admin_ops) == MAX_PENDING_ADMIN_OPS - 1
+ assert _root_add(s, "/srv/next")["type"] == "admin_challenge"
+ assert all(time.time() - p["ts"] < 5 for p in s._admin_ops.values())
+
+
+# ── What a challenge covers (docs/MESHBAY_DESIGN.md §5.4) ────────────────────
+#
+# The signature covers the subject and nothing else of a request, so every value
+# the executor acts on has to be in it.
+
+def test_root_add_signs_whether_members_may_write():
+ from meshbay_common.adminop import root_add_subject
+ s = _member_session()
+ s._dispatch_message({"type": "root_add", "group_id": GROUP, "path": "/srv/drop",
+ "name": "Drop", "writable": True, "removable": False})
+ challenge = s._channel.sent[-1]
+ assert challenge["subject"] == root_add_subject("/srv/drop", "Drop", "generic",
+ True, False)
+ assert challenge["subject"] != root_add_subject("/srv/drop", "Drop", "generic",
+ False, False)
+
+
+def test_group_attach_signs_the_directory_it_exposes():
+ from meshbay_common.adminop import group_attach_subject
+ s = _member_session()
+ s._dispatch_message({"type": "group_attach", "name": "photos",
+ "shared_dir": "/home/me/Photos"})
+ assert s._channel.sent[-1]["subject"] == group_attach_subject(
+ "photos", "/home/me/Photos", True)
+
+
+def test_invite_create_signs_the_name_it_records():
+ from meshbay_common.adminop import invite_create_subject
+ s = _member_session()
+ s._ctx["roster"] = object() # only its presence is checked before the challenge
+ s._dispatch_message({"type": "invite_create", "group_id": GROUP,
+ "user_id": "u-1", "username": "alice"})
+ assert s._channel.sent[-1]["subject"] == invite_create_subject("u-1", "alice")
+
+
+def test_tmdb_config_signs_the_token_without_writing_it():
+ from meshbay_common.adminop import tmdb_config_subject
+ s = _member_session()
+ s._dispatch_message({"type": "tmdb_config", "token": "secret-token",
+ "language": "fr-FR"})
+ subject = s._channel.sent[-1]["subject"]
+ assert subject == tmdb_config_subject("secret-token", "fr-FR")
+ assert "secret-token" not in subject
diff --git a/packages/meshbay-node/tests/test_tmdb_config_policy.py b/packages/meshbay-node/tests/test_tmdb_config_policy.py
index 6a51eb0..c671ac8 100644
--- a/packages/meshbay-node/tests/test_tmdb_config_policy.py
+++ b/packages/meshbay-node/tests/test_tmdb_config_policy.py
@@ -22,7 +22,7 @@ from pathlib import Path
import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from meshbay_common.adminop import OP_TMDB_CONFIG
+from meshbay_common.adminop import OP_TMDB_CONFIG, tmdb_config_subject
from meshbay_node.indexer.group_index import GroupIndex
from meshbay_node.roster import Roster
from meshbay_node.transport.webrtc_server import WebRTCPeerSession
@@ -134,7 +134,9 @@ async def test_subject_reflects_whether_a_token_was_supplied(tmp_path):
session._do_tmdb_config({"token": "x"})
_, subject, _, _ = issued[0]
- assert subject == "custom_token=yes,language=default"
+ # The token is bound by its digest and never written into the subject.
+ assert subject == tmdb_config_subject("x", None)
+ assert "sha256:" in subject and tmdb_config_subject("y", None) != subject
async def test_subject_says_no_custom_token_when_none_given(tmp_path):
@@ -146,7 +148,10 @@ async def test_subject_says_no_custom_token_when_none_given(tmp_path):
session._do_tmdb_config({})
_, subject, _, _ = issued[0]
- assert subject == "custom_token=no,language=default"
+ # Nothing given means both unchanged — distinct from clearing either.
+ assert subject == tmdb_config_subject(None, None)
+ assert subject != tmdb_config_subject("", None)
+ assert subject != tmdb_config_subject(None, "")
async def test_subject_reflects_a_configured_language(tmp_path):
@@ -158,7 +163,7 @@ async def test_subject_reflects_a_configured_language(tmp_path):
session._do_tmdb_config({"language": "fr-FR"})
_, subject, payload, _ = issued[0]
- assert subject == "custom_token=no,language=fr-FR"
+ assert subject == tmdb_config_subject(None, "fr-FR")
assert payload["language"] == "fr-FR"
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index 4542817..7a6f517 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -34,6 +34,7 @@ from meshbay_common.adminop import (
OP_INVITE_CREATE,
OP_INVITE_LINK_CREATE,
admin_transcript,
+ invite_create_subject,
)
from meshbay_common.crypto import (
generate_gek,
@@ -1335,7 +1336,8 @@ async def test_invite_then_join_delivers_the_gek(sk_node, sk_hub, gek, shared_di
challenge_msg = await asyncio.wait_for(q_admin.get(), timeout=5.0)
assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE
assert challenge_msg["op"] == OP_INVITE_CREATE
- assert challenge_msg["subject"] == "user-002"
+ # The name the invitation records is signed with the account it is for.
+ assert challenge_msg["subject"] == invite_create_subject("user-002", "bob")
ch_admin.send(_pack({
"type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION,