aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-common/src')
-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
8 files changed, 77 insertions, 128 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/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",