diff options
Diffstat (limited to 'packages/meshbay-common')
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"] |