aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-common')
-rw-r--r--packages/meshbay-common/pyproject.toml1
-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/crypto.py92
-rw-r--r--packages/meshbay-common/src/meshbay_common/groupbox.py8
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py2
-rw-r--r--packages/meshbay-common/src/meshbay_common/paths.py4
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py16
-rw-r--r--packages/meshbay-common/src/meshbay_common/webcrypto.py27
-rw-r--r--packages/meshbay-common/tests/test_admin_subject_parity.py145
-rw-r--r--packages/meshbay-common/tests/test_groupbox.py4
-rw-r--r--packages/meshbay-common/tests/test_portable_name_parity.py78
-rw-r--r--packages/meshbay-common/tests/test_webcrypto.py27
13 files changed, 302 insertions, 158 deletions
diff --git a/packages/meshbay-common/pyproject.toml b/packages/meshbay-common/pyproject.toml
index a454521..9f43cf2 100644
--- a/packages/meshbay-common/pyproject.toml
+++ b/packages/meshbay-common/pyproject.toml
@@ -12,7 +12,6 @@ dependencies = [
"PyJWT>=2.9",
"blake3>=1.0",
"msgpack>=1.1",
- "zstandard>=0.23",
]
[project.optional-dependencies]
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/crypto.py b/packages/meshbay-common/src/meshbay_common/crypto.py
index e537500..8fb80f8 100644
--- a/packages/meshbay-common/src/meshbay_common/crypto.py
+++ b/packages/meshbay-common/src/meshbay_common/crypto.py
@@ -41,25 +41,6 @@ def generate_gek() -> bytes:
"""Generate a fresh 256-bit Group Encryption Key."""
return ChaCha20Poly1305.generate_key()
-def chunk_key(gek: bytes, file_hash: bytes, chunk_index: int) -> bytes:
- """Derive a per-chunk encryption key from the GEK (deterministic)."""
- return HKDF(
- algorithm=hashes.SHA256(),
- length=32,
- salt=None,
- info=b"file:" + file_hash + b":chunk:" + chunk_index.to_bytes(4, "big"),
- ).derive(gek)
-
-def encrypt_chunk(key: bytes, plaintext: bytes) -> tuple[bytes, bytes]:
- """Encrypt plaintext with ChaCha20-Poly1305. Returns (nonce, ciphertext)."""
- nonce = os.urandom(12)
- ct = ChaCha20Poly1305(key).encrypt(nonce, plaintext, None)
- return nonce, ct
-
-def decrypt_chunk(key: bytes, nonce: bytes, ciphertext: bytes) -> bytes:
- """Decrypt ciphertext. Raises InvalidTag on authentication failure."""
- return ChaCha20Poly1305(key).decrypt(nonce, ciphertext, None)
-
def file_hash(path_or_bytes) -> bytes:
"""Compute blake3 hash of a file (bytes or path-like)."""
if isinstance(path_or_bytes, (str, bytes)) and not isinstance(path_or_bytes, bytes):
@@ -73,58 +54,6 @@ def file_hash(path_or_bytes) -> bytes:
# ── GEK wrapping (ECIES-like) ─────────────────────────────────────────────────
-GEK_WRAP_INFO = b"meshbay:gek_wrap:v1"
-
-def wrap_gek(gek: bytes, pk_recipient: bytes) -> dict:
- """
- Wrap a GEK for a recipient using ephemeral X25519 + HKDF + ChaCha20-Poly1305.
-
- Protocol:
- 1. Generate ephemeral (sk_eph, pk_eph)
- 2. shared = X25519(sk_eph, pk_recipient)
- 3. wrap_key = HKDF(shared, salt=pk_eph, info=GEK_WRAP_INFO)
- 4. wrapped = ChaCha20-Poly1305(wrap_key).encrypt(nonce, gek, aad=pk_recipient)
-
- The hub stores {pk_eph, nonce, wrapped} — opaque, cannot decrypt.
- """
- sk_eph = X25519PrivateKey.generate()
- pk_eph_raw = pk_to_raw(sk_eph.public_key())
-
- shared = sk_eph.exchange(X25519PublicKey.from_public_bytes(pk_recipient))
- wrap_key = HKDF(
- algorithm=hashes.SHA256(), length=32,
- salt=pk_eph_raw, info=GEK_WRAP_INFO,
- ).derive(shared)
-
- nonce = os.urandom(12)
- wrapped = ChaCha20Poly1305(wrap_key).encrypt(nonce, gek, pk_recipient)
-
- return {
- "pk_eph_b64": base64.b64encode(pk_eph_raw).decode(),
- "nonce_b64": base64.b64encode(nonce).decode(),
- "wrapped_b64": base64.b64encode(wrapped).decode(),
- }
-
-def unwrap_gek(bundle: dict, sk_recipient: bytes, pk_recipient: bytes) -> bytes:
- """
- Unwrap a GEK bundle using the recipient's X25519 private key.
- Raises InvalidTag if the key is wrong or the bundle was tampered.
- """
- pk_eph_raw = base64.b64decode(bundle["pk_eph_b64"])
- nonce = base64.b64decode(bundle["nonce_b64"])
- wrapped = base64.b64decode(bundle["wrapped_b64"])
-
- shared = X25519PrivateKey.from_private_bytes(sk_recipient).exchange(
- X25519PublicKey.from_public_bytes(pk_eph_raw)
- )
- wrap_key = HKDF(
- algorithm=hashes.SHA256(), length=32,
- salt=pk_eph_raw, info=GEK_WRAP_INFO,
- ).derive(shared)
-
- return ChaCha20Poly1305(wrap_key).decrypt(nonce, wrapped, pk_recipient)
-
-
GEK_WRAP_INFO_AES = b"meshbay:gek_wrap:v1:aes"
def wrap_gek_aes(gek: bytes, pk_recipient: bytes) -> dict:
@@ -220,24 +149,3 @@ def decrypt_keystore(iv: bytes, ciphertext: bytes, tag: bytes, key: bytes) -> by
"""Decrypt keystore blob. Raises on authentication failure."""
dec = Cipher(algorithms.AES(key), modes.GCM(iv, tag)).decryptor()
return dec.update(ciphertext) + dec.finalize()
-
-# ── Chunk signing ─────────────────────────────────────────────────────────────
-
-def sign_chunk(sk_node: Ed25519PrivateKey, chunk_index: int,
- nonce: bytes, ct_hash: bytes) -> bytes:
- """
- Sign chunk metadata. Payload: chunk_index || nonce || ct_hash.
-
- Despite the name, this no longer signs file chunks — Phase 9.15 dropped per-chunk
- signatures on the WebRTC path and 2026-09-03 dropped the QUIC copy that had been
- left behind. Its one caller is `GroupIndex.serialize()`, which signs a whole index
- envelope under the pseudo-index `INDEX_CHUNK`.
- """
- payload = chunk_index.to_bytes(4, "big") + nonce + ct_hash
- return sk_node.sign(payload)
-
-def verify_chunk_signature(pk_node: Ed25519PublicKey, chunk_index: int,
- nonce: bytes, ct_hash: bytes, signature: bytes) -> None:
- """Verify chunk signature. Raises InvalidSignature on failure."""
- payload = chunk_index.to_bytes(4, "big") + nonce + ct_hash
- pk_node.verify(signature, payload)
diff --git a/packages/meshbay-common/src/meshbay_common/groupbox.py b/packages/meshbay-common/src/meshbay_common/groupbox.py
index 3b45dba..260e362 100644
--- a/packages/meshbay-common/src/meshbay_common/groupbox.py
+++ b/packages/meshbay-common/src/meshbay_common/groupbox.py
@@ -32,10 +32,10 @@ it is an oversight. The node holds the GEK for its own group, so unlike the inde
this direction seals *towards* the node: it opens the payload before it writes
anything to disk, and refuses a chunk that does not open rather than guessing.
-Purpose separation is deliberate. `GroupIndex.serialize()` reuses
-`chunk_key_aes(gek, file_hash, chunk_index)` with a pseudo-file ("the index as chunk
-0 of a virtual index file"), which borrows a file's key space for something that is
-not a file. Each purpose here derives its own subkey instead.
+Purpose separation is deliberate. The alternative — reusing
+`chunk_key_aes(gek, file_hash, chunk_index)` with a pseudo-file, "the index as chunk
+0 of a virtual index file" — borrows a file's key space for something that is not a
+file. Each purpose here derives its own subkey instead.
"""
from __future__ import annotations
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/src/meshbay_common/paths.py b/packages/meshbay-common/src/meshbay_common/paths.py
index 45bf34e..b673649 100644
--- a/packages/meshbay-common/src/meshbay_common/paths.py
+++ b/packages/meshbay-common/src/meshbay_common/paths.py
@@ -137,7 +137,9 @@ def sanitize_for_download(name: str, *, replacement: str = "_") -> str:
For the client saving a file, never for the node storing one. Returns the
name unchanged when it is already portable, so the common case is identity
- and the caller can tell whether it renamed anything by comparing.
+ and the caller can tell whether it renamed anything by comparing. The
+ browser's copy is `static/portable-name.js`, held to this one by
+ `test_portable_name_parity.py`.
"""
if is_portable_name(name):
return name
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index 6b929cd..af2d93b 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -30,7 +30,6 @@ number — it is a label chosen by the peer, and the only thing it decides is
which local promise a reply belongs to.
"""
-import os
from dataclasses import dataclass, field
# The wire versions live in meshbay_common/__init__.py — one source, because a
@@ -75,7 +74,6 @@ class MNP:
# plaintext form on the wire (`chatbox.py`, docs/MESHBAY_DESIGN.md §4.5);
# `format` distinguishes a *stored* pre-2.0 row, which is still served.
CHAT_MESSAGE = "chat_msg" # one chat message, sealed and signed
- CHAT_ATTACHMENT = "chat_attach" # attachment metadata
CHAT_HISTORY = "chat_hist" # request message history (newest, or before a cursor)
CHAT_HISTORY_RESPONSE = "chat_hist_resp" # history response with messages
# Link unfurl: the node fetches a URL a member pasted and returns an
@@ -123,7 +121,6 @@ class MNP:
STREAM_END = "stream_end" # node signals end of stream
STREAM_MORE = "stream_more" # client → node: room for N more segments
STREAM_STOP = "stream_stop" # client → node: nobody is watching any more
- EPHEMERAL_STREAM = "ephemeral_stream" # reserved — mobile live push
HANDSHAKE_CHALLENGE = "handshake_challenge" # node → client: GEK proof nonce
HANDSHAKE_RESPONSE = "handshake_response" # client → node: HMAC(GEK, nonce)
ADMIN_CHALLENGE = "admin_challenge" # node → client: Ed25519 sign challenge
@@ -318,9 +315,8 @@ class IndexEntry:
def index_entry_wire(e: IndexEntry) -> dict:
"""
- The wire-dict shape used by INDEX_SYNC/INDEX_DELTA hand-built messages
- (as opposed to GroupIndex.serialize()'s asdict() encoding of the whole
- index). Centralized so the three call sites that build these
+ The wire-dict shape used by INDEX_SYNC/INDEX_DELTA hand-built messages.
+ Centralized so the three call sites that build these
(webrtc/files.py's _do_index_sync, daemon._broadcast_index_change's two
branches) can't drift from each other as fields are added.
"""
@@ -369,8 +365,7 @@ class IndexDelta:
# under a key derived from the GEK, which only group members hold, and since C3 the
# node authenticates itself in the handshake and is pinned by the client. A
# signature per chunk re-proved, once per megabyte, what the session established
-# once. (`sign_chunk` still exists in `crypto.py` — it signs the serialized index
-# envelope, which is a different artifact; see `indexer/group_index.py`.)
+# once.
def chunk_ciphertext(
@@ -460,11 +455,6 @@ def file_chunk_plaintext(
UPLOAD_ID_LEN = 16 # 128 bits of client-chosen correlation, hex on the wire
-def new_upload_id() -> str:
- """A fresh correlation id for one upload."""
- return os.urandom(UPLOAD_ID_LEN).hex()
-
-
def file_upload_wire(
gek: bytes,
group_id: str,
diff --git a/packages/meshbay-common/src/meshbay_common/webcrypto.py b/packages/meshbay-common/src/meshbay_common/webcrypto.py
index 3bd5ae6..7119e2e 100644
--- a/packages/meshbay-common/src/meshbay_common/webcrypto.py
+++ b/packages/meshbay-common/src/meshbay_common/webcrypto.py
@@ -1,28 +1,21 @@
"""
-MeshBay — AES-256-GCM cipher variant for browser-accessible groups.
+MeshBay — the content cipher: AES-256-GCM, per-chunk keys derived from the GEK.
-The ChaCha20-Poly1305 GEK used in MNP (TCP+TLS and QUIC transport)
-is NOT available in the WebCrypto API. For groups whose content must
-be decryptable by a web browser (using SubtleCrypto), an AES-256-GCM
-variant is used instead.
-
-The GEK wrapping (X25519 + HKDF) is identical — only the content
-cipher changes. The hub stores and distributes GEK bundles the same way.
-
-Cipher selection is declared per-group in the hub registry:
- "cipher": "chacha20-poly1305" (default, native clients)
- "cipher": "aes-256-gcm" (browser-compatible groups)
+AES-GCM because it is what WebCrypto offers, and one cipher serves every client:
+the browser, the desktop client (the same engine) and the Python side here.
Python side (this module):
- encrypt_chunk_aes / decrypt_chunk_aes
+ chunk_key_aes / encrypt_chunk_aes / decrypt_chunk_aes
JavaScript side (in static/crypto.js):
- Uses SubtleCrypto.importKey + SubtleCrypto.decrypt with AES-GCM.
+ SubtleCrypto.importKey + SubtleCrypto.decrypt with AES-GCM.
-Key derivation for AES variant — same HKDF info string with suffix:
+Key derivation:
info = b"file:" + file_hash + b":chunk:" + chunk_index + b":aes"
-This ensures AES and ChaCha20 keys are always distinct even from the same GEK.
+The `:aes` suffix dates from a ChaCha20-Poly1305 variant derived from the same
+GEK without it, which nothing used and which is gone. It stays: it is part of
+every chunk key in existence, and changing it would change them all.
"""
import os
@@ -33,7 +26,7 @@ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
def chunk_key_aes(gek: bytes, file_hash: bytes, chunk_index: int) -> bytes:
- """Derive a per-chunk AES-256 key. Distinct from ChaCha20 key."""
+ """Derive a per-chunk AES-256 key from the GEK."""
return HKDF(
algorithm=hashes.SHA256(), length=32, salt=None,
info=b"file:" + file_hash + b":chunk:" + chunk_index.to_bytes(4, "big") + b":aes",
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-common/tests/test_groupbox.py b/packages/meshbay-common/tests/test_groupbox.py
index 65d8ce6..6e687ea 100644
--- a/packages/meshbay-common/tests/test_groupbox.py
+++ b/packages/meshbay-common/tests/test_groupbox.py
@@ -45,8 +45,8 @@ def test_purposes_are_separate_key_spaces(gek):
The reason there are two info strings rather than one key reused.
An ack sealed under the index subkey would otherwise be openable by anything
- holding the index subkey, which is the confusion `GroupIndex.serialize()`'s
- "the index as chunk 0 of a virtual index file" creates for chunk keys.
+ holding the index subkey — the confusion that treating "the index as chunk 0 of
+ a virtual index file" would create for chunk keys.
"""
assert group_key(gek, PURPOSE_INDEX) != group_key(gek, PURPOSE_ACK)
sealed = seal(gek, PURPOSE_INDEX, "index_sync", "g1", PAYLOAD)
diff --git a/packages/meshbay-common/tests/test_portable_name_parity.py b/packages/meshbay-common/tests/test_portable_name_parity.py
new file mode 100644
index 0000000..3a491a7
--- /dev/null
+++ b/packages/meshbay-common/tests/test_portable_name_parity.py
@@ -0,0 +1,78 @@
+"""
+The browser makes a name writable everywhere exactly as Python does.
+
+`static/portable-name.js` renames a file at the moment a member saves it;
+`meshbay_common.paths.sanitize_for_download` is the same rule in Python. Two
+copies of a rule that differ decide differently which files get renamed, so the
+real module runs under node here against the real Python.
+
+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.paths import is_portable_name, sanitize_for_download
+
+PORTABLE_JS = (Path(__file__).resolve().parents[2]
+ / "meshbay-hub" / "src" / "meshbay_hub" / "static" / "portable-name.js")
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not PORTABLE_JS.exists(),
+ reason="node or portable-name.js unavailable — parity cannot be checked",
+)
+
+NAMES = [
+ "plain.txt", "Réunion 12:30.pdf", 'a<b>c:d"e/f\\g|h?i*j.txt', "tab\tnew\nline",
+ "ends with dot.", "ends with space ", "trailing . . ", "CON", "con.txt", "aux.tar.gz",
+ "COM1", "com10.txt", "LPT9.log", "nul.", ".", "..", "", " ", ".bashrc", "...",
+ "名前:ファイル.mkv", "emoji 🙂?.png", "\x01\x1f.bin", "prn .txt", "Con",
+ "a.b.c", "COM1.", "normal name (2).mp4",
+]
+
+_HARNESS = r"""
+const fs = require('fs');
+const src = fs.readFileSync(process.argv[2], 'utf8').replace(/^export /gm, '');
+const M = new Function(src + '\nreturn { portableName, portablePath };')();
+const names = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
+process.stdout.write(JSON.stringify({
+ names: names.map((n) => M.portableName(n)),
+ path: M.portablePath('Top:Folder/sub//aux.txt'),
+}));
+"""
+
+
+@pytest.fixture(scope="module")
+def js(tmp_path_factory):
+ d = tmp_path_factory.mktemp("portable")
+ (d / "harness.js").write_text(_HARNESS, encoding="utf-8")
+ (d / "names.json").write_text(json.dumps(NAMES), encoding="utf-8")
+ proc = subprocess.run(
+ ["node", str(d / "harness.js"), str(PORTABLE_JS), str(d / "names.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)
+
+
+@pytest.mark.parametrize("i,name", list(enumerate(NAMES)))
+def test_the_browser_renames_as_python_does(i, name, js):
+ assert js["names"][i] == sanitize_for_download(name)
+
+
+@pytest.mark.parametrize("name", NAMES)
+def test_what_comes_out_can_be_written_everywhere(name):
+ out = sanitize_for_download(name)
+ assert is_portable_name(out), (name, out)
+ assert sanitize_for_download(out) == out
+
+
+def test_a_portable_name_is_left_alone():
+ assert sanitize_for_download("plain.txt") == "plain.txt"
+
+
+def test_a_path_is_made_portable_segment_by_segment(js):
+ assert js["path"] == "Top_Folder/sub//aux_.txt"
diff --git a/packages/meshbay-common/tests/test_webcrypto.py b/packages/meshbay-common/tests/test_webcrypto.py
index fbffdc1..ffe3f36 100644
--- a/packages/meshbay-common/tests/test_webcrypto.py
+++ b/packages/meshbay-common/tests/test_webcrypto.py
@@ -17,17 +17,6 @@ def test_aes_roundtrip():
assert decrypt_chunk_aes(key, nonce, ct) == data
-def test_aes_key_distinct_from_chacha_key():
- """AES and ChaCha20 keys for the same chunk must differ."""
- from meshbay_common.crypto import chunk_key as chacha_key
- gek = generate_gek()
- data = os.urandom(100)
- fh = blake3.blake3(data).digest()
- aes_k = chunk_key_aes(gek, fh, 0)
- chacha_k = chacha_key(gek, fh, 0)
- assert aes_k != chacha_k
-
-
def test_aes_wrong_key_rejected():
gek = generate_gek()
data = b"private content"
@@ -76,19 +65,3 @@ def test_aes_gek_wrap_wrong_key_rejected():
bundle = wrap_gek_aes(gek, pk_to_raw(sk_a.public_key()))
with pytest.raises(Exception):
unwrap_gek_aes(bundle, sk_to_raw(sk_b), pk_to_raw(sk_b.public_key()))
-
-
-def test_aes_gek_wrap_differs_from_chacha_wrap():
- from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
- from meshbay_common.crypto import (
- pk_to_raw,
- wrap_gek,
- wrap_gek_aes,
- )
- gek = generate_gek()
- sk = X25519PrivateKey.generate()
- pk_raw = pk_to_raw(sk.public_key())
-
- bundle_aes = wrap_gek_aes(gek, pk_raw)
- bundle_chacha = wrap_gek(gek, pk_raw)
- assert bundle_aes["wrapped_b64"] != bundle_chacha["wrapped_b64"]