diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-05 10:16:28 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-05 10:16:28 +0200 |
| commit | 41d015137d6d9c097e462cd0662e855c3253d001 (patch) | |
| tree | e1e5a4e6f1415509c031e079c2b753591975ed05 /examples | |
| parent | fe64864e8cfd2cca3a70ae375a3ea16d537ae6be (diff) | |
| download | meshbay-41d015137d6d9c097e462cd0662e855c3253d001.tar.gz | |
docs: add Python examples for the hub, a node and its control API
examples/ holds small scripts that talk to MeshBay the way the application
does, with the values to change as variables at the top of each one:
- meshbay_session.py: hub sign-in, WebRTC to a node serving the group, and
the handshake (identity bundle, group key, proofs both ways)
- list_groups.py: the user's groups, up or down
- download.py: one file, decrypted chunk by chunk under a transfer lease
- upload.py: one file, sealed in 48 KiB chunks into a group folder
- create_group.py: creates a group on the hub and hosts it on the local node
through its loopback control API
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'examples')
| -rw-r--r-- | examples/README.md | 38 | ||||
| -rw-r--r-- | examples/create_group.py | 80 | ||||
| -rwxr-xr-x | examples/download.py | 48 | ||||
| -rw-r--r-- | examples/list_groups.py | 31 | ||||
| -rw-r--r-- | examples/meshbay_session.py | 251 | ||||
| -rw-r--r-- | examples/upload.py | 59 |
6 files changed, 507 insertions, 0 deletions
diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..f13e37a --- /dev/null +++ b/examples/README.md @@ -0,0 +1,38 @@ +# Examples + +Small programs that talk to MeshBay the way the application does, without the +application. The values to change are variables at the top of each script. + +| File | What it does | +|---|---| +| `meshbay_session.py` | The shared part: signs in to the hub, connects to a node serving the group over WebRTC, and completes the handshake. Used by the scripts below | +| `list_groups.py` | Lists your groups, `up` when a node serving the group is connected to the hub, `down` otherwise | +| `create_group.py` | Creates a group on the hub and hosts it on the node running on this machine, through the node's control API on 127.0.0.1 | +| `download.py` | Downloads one file from a group and saves it decrypted | +| `upload.py` | Uploads one file into a folder of a group | + +```bash +# From the root of the repository +python3 -m venv .venv +.venv/bin/pip install -e packages/meshbay-common aiortc httpx argon2-cffi + +# Edit the variables at the top of the script, then +cd examples +../.venv/bin/python download.py +``` + +The hub is `https://meshbay.org` by default. Paths in a group start with the +root's name, then the folders, as in the group's file browser: `FILE_PATH = +"shared/2024/beach.jpg"`, `DEST_DIR = "shared/2024"`. An upload needs a root the +operator made writable, and never replaces a file: on a name clash the node +picks a free name and says which. + +Why not curl: files never pass through the hub. They travel over a WebRTC +DataChannel, straight between you and the node, encrypted under the group key. +Signing in, the handshake with the node and the encryption all happen in the +scripts. + +They need the node to keep a copy of your identity keys, sealed so that only +your passphrase and your hub account can open it. That is the case when +*Browser access* is on in the application's settings, or once you have opened +the group in a browser. diff --git a/examples/create_group.py b/examples/create_group.py new file mode 100644 index 0000000..c03e5e8 --- /dev/null +++ b/examples/create_group.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +Create a group and host it on the node running on this machine. + +Run it on the node's machine, as the user the node runs as: the node's control +API only answers on 127.0.0.1, with a token the node writes to its data folder. +USERNAME must be the hub account the node is linked to. +""" + +import sys +import time +from pathlib import Path + +import httpx +from meshbay_session import auth_key + +HUB = "https://meshbay.org" +USERNAME = "alice" +PASSPHRASE = "your passphrase" +GROUP_NAME = "Family Photos" +DESCRIPTION = "" +FOLDER = "~/Pictures/shared" # the folder to share, created if missing +WRITABLE = True # whether members may upload into it + +NODE_API = "http://127.0.0.1:18000" # ui_port in node.toml +TOKEN_FILE = "~/.local/share/meshbay/ui-token" + + +def call(client: httpx.Client, method: str, path: str, **kwargs) -> dict: + r = client.request(method, path, **kwargs) + if r.status_code >= 400: + sys.exit(f"{method} {path} refused: {r.text}") + return r.json() + + +def main() -> None: + # 1. Create the group on the hub. It is only a name and a member list there. + with httpx.Client(base_url=HUB, timeout=30) as hub: + login = call(hub, "POST", "/v1/users/login", json={ + "username": USERNAME, "auth_key": auth_key(USERNAME, PASSPHRASE)}) + auth = {"Authorization": f"Bearer {login['access_token']}"} + body = {"name": GROUP_NAME, "visibility": "private", "join_policy": "invite"} + if DESCRIPTION: + body["description"] = DESCRIPTION + group_id = call(hub, "POST", "/v1/groups", headers=auth, json=body)["group_id"] + print(f"Created {GROUP_NAME!r} on the hub ({group_id})") + + # The node changes its token at every start: read it now. + token = Path(TOKEN_FILE).expanduser().read_text().strip() + with httpx.Client(base_url=NODE_API, timeout=30, + headers={"X-MeshBay-Token": token}) as node: + + # 2. Ask the node to host it. The node writes the group into node.toml, + # then indexes the folder. + call(node, "POST", "/api/groups/attach", json={ + "name": group_id, "shared_dir": FOLDER, + "writable": WRITABLE, "join_policy": "invite"}) + while True: + hosted = call(node, "GET", "/api/groups") + group = next((g for g in hosted["groups"] if g["id"] == group_id), None) + if group: + break + print("Waiting for the node to index the folder...") + time.sleep(2) + + # 3. The group key. It never leaves the node, except wrapped for a member. + call(node, "POST", f"/api/groups/{group_id}/gek") + + # 4. First group on this node: a one-time code makes your application + # its operator. + pair = None if hosted["operator_paired"] else call(node, "POST", "/api/operator/pair") + + print(f"Hosted on this node: {group['file_count']} files indexed") + if pair: + print(f"Operator code, to enter when the application asks: {pair['code']} " + f"(valid until {pair['expires_at']})") + + +if __name__ == "__main__": + main() diff --git a/examples/download.py b/examples/download.py new file mode 100755 index 0000000..b5359d2 --- /dev/null +++ b/examples/download.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Download one file from a MeshBay group, as a member, without a browser.""" + +import asyncio +import math +import sys + +from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes +from meshbay_session import connect + +HUB = "https://meshbay.org" +USERNAME = "alice" +PASSPHRASE = "your passphrase" +GROUP = "Family Photos" # the group's name, as shown in the sidebar +NODE_ID = "" # empty: the first node online for this group +FILE_PATH = "shared/2024/beach.jpg" # root name, then folders, then the file name +OUTPUT = "beach.jpg" + +CHUNK_SIZE = 1024 * 1024 # the node serves files in 1 MiB chunks + + +async def main() -> None: + session = await connect(HUB, USERNAME, PASSPHRASE, GROUP, NODE_ID) + + entry = next((e for e in await session.index() + if f"{e['path']}/{e['name']}" == FILE_PATH), None) + if entry is None: + sys.exit(f"No file {FILE_PATH!r} in this group") + + # Each chunk is encrypted under its own key, derived from the group key. + chunks = max(1, math.ceil(entry["size"] / CHUNK_SIZE)) + tr = await session.open_transfer("download", entry["size"], chunks) + file_hash = bytes.fromhex(entry["id"]) + with open(OUTPUT, "wb") as out: + for i in range(chunks): + session.ch.send({"type": "file_req", "v": "0.1", "file_id": entry["id"], + "chunk_index": i, "tr": tr}) + chunk = await session.ch.recv("file_chunk") + out.write(decrypt_chunk_aes(chunk_key_aes(session.gek, file_hash, i), + chunk["nonce"], chunk["ct"])) + print(f"\r{i + 1}/{chunks} chunks", end="", flush=True) + session.close_transfer(tr) + print(f"\nSaved {OUTPUT} ({entry['size']} bytes)") + await session.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/list_groups.py b/examples/list_groups.py new file mode 100644 index 0000000..61773e2 --- /dev/null +++ b/examples/list_groups.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""List a user's groups, and whether a node serving each one is up.""" + +import sys + +import httpx +from meshbay_session import auth_key + +HUB = "https://meshbay.org" +USERNAME = "alice" +PASSPHRASE = "your passphrase" + + +def main() -> None: + with httpx.Client(base_url=HUB, timeout=30) as hub: + r = hub.post("/v1/users/login", + json={"username": USERNAME, "auth_key": auth_key(USERNAME, PASSPHRASE)}) + if r.status_code != 200: + sys.exit(f"Sign-in refused: {r.text}") + auth = {"Authorization": f"Bearer {r.json()['access_token']}"} + groups = hub.get("/v1/groups/mine", headers=auth).json()["groups"] + + # "up" means a node serving the group is connected to the hub right now. + # It does not prove that this machine can reach that node. + for g in groups: + state = "up" if g["node_online"] else "down" + print(f"{state:<6}{g['name']} (@{g['owner_username']})") + + +if __name__ == "__main__": + main() diff --git a/examples/meshbay_session.py b/examples/meshbay_session.py new file mode 100644 index 0000000..86bf17e --- /dev/null +++ b/examples/meshbay_session.py @@ -0,0 +1,251 @@ +""" +A member's session with a MeshBay node, shared by the examples. + +The hub only signs you in and relays the WebRTC offer. Everything after that +goes straight to the node, encrypted under the group key. +""" + +import asyncio +import base64 +import hashlib +import json +import os +import struct +import sys +import time + +import httpx +import msgpack +from aiortc import RTCConfiguration, RTCIceServer, RTCPeerConnection, RTCSessionDescription +from argon2.low_level import Type, hash_secret_raw +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.primitives.serialization import load_der_private_key +from meshbay_common import MNP_VERSION +from meshbay_common.crypto import pk_to_b64, unwrap_gek_aes +from meshbay_common.groupbox import PURPOSE_ACK, PURPOSE_INDEX, unseal +from meshbay_common.handshake import ( + MNP_MIN_SUPPORTED, + challenge_transcript, + check_version, + handshake_transcript, + make_proof, + verify_proof, + webrtc_binding, +) +from meshbay_common.join import join_transcript + + +def b64(data: bytes) -> str: + return base64.b64encode(data).decode() + + +def unb64(text: str) -> bytes: + return base64.b64decode(text) + + +def hkdf(key: bytes, info: str) -> bytes: + return HKDF(algorithm=hashes.SHA256(), length=32, salt=None, + info=info.encode()).derive(key) + + +# Keys derived from the passphrase, the same way as keyderive.js + +def auth_key(username: str, passphrase: str) -> str: + """What the hub checks at sign-in. The passphrase itself never leaves here.""" + salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest() + return b64(PBKDF2HMAC(algorithm=hashes.SHA512(), length=32, salt=salt, + iterations=600_000).derive(passphrase.encode())) + + +def bundle_master_key(username: str, passphrase: str, user_id: str, pepper_b64: str) -> bytes: + """Argon2id of the passphrase, mixed with the pepper the hub returns at sign-in.""" + salt = hashlib.sha256(f"meshbay:bundle:v2:{username}".encode()).digest()[:16] + a = hash_secret_raw(passphrase.encode(), salt, time_cost=3, memory_cost=131072, + parallelism=1, hash_len=32, type=Type.ID) + return hkdf(a + unb64(pepper_b64), f"meshbay:bundle-master:v3|{user_id}") + + +def open_identity(bundle_b64: str, master: bytes, user_id: str, node_pk: str): + """Open your identity keys for this node. The node stores them sealed.""" + raw = unb64(bundle_b64) # "MBK3", pepper version, nonce, ciphertext + key = hkdf(master, f"meshbay:bundle:v3|node|{node_pk}") + aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode() + keys = json.loads(AESGCM(key).decrypt(raw[5:17], raw[17:], aad)) + sk_ed = load_der_private_key(unb64(keys["skEd"]), password=None) + sk_x = load_der_private_key(unb64(keys["skX"]), password=None) + return sk_ed, sk_x + + +# MNP over a WebRTC DataChannel + +class Channel: + """Frames are a 4-byte big-endian length followed by msgpack.""" + + def __init__(self, dc): + self.dc = dc + self.buf = bytearray() + self.queue: asyncio.Queue = asyncio.Queue() + dc.on("message", self._on_message) + + def _on_message(self, data): + self.buf.extend(data if isinstance(data, bytes) else data.encode()) + while len(self.buf) >= 4: + size = struct.unpack(">I", self.buf[:4])[0] + if len(self.buf) < 4 + size: + break + self.queue.put_nowait(msgpack.unpackb(bytes(self.buf[4:4 + size]), raw=False)) + del self.buf[:4 + size] + + def send(self, msg: dict) -> None: + data = msgpack.packb(msg, use_bin_type=True) + self.dc.send(struct.pack(">I", len(data)) + data) + + async def recv(self, *types: str) -> dict: + """The next message of one of these types. Pushes from the node are skipped.""" + while True: + msg = await asyncio.wait_for(self.queue.get(), timeout=60) + if msg.get("type") == "error": + sys.exit(f"Node refused: {msg.get('detail') or msg}") + if msg.get("type") in types: + return msg + + +def dtls_fingerprint(sdp: str) -> bytes: + for line in sdp.splitlines(): + if line.startswith("a=fingerprint:sha-256 "): + return bytes.fromhex(line.split(" ", 1)[1].strip().replace(":", "")) + sys.exit("No DTLS fingerprint in the SDP") + + +class Session: + """An authenticated connection to one node, for one group.""" + + def __init__(self, pc, ch: Channel, group_id: str, gek: bytes): + self.pc = pc + self.ch = ch + self.group_id = group_id + self.gek = gek + + async def index(self) -> list[dict]: + """The group's files. Each entry's path is its root name, then its folders.""" + self.ch.send({"type": "index_sync", "v": "0.1"}) + msg = await self.ch.recv("index_sync") + return unseal(self.gek, PURPOSE_INDEX, "index_sync", self.group_id, msg)["entries"] + + async def open_transfer(self, kind: str, size: int, chunks: int) -> str: + """Ask the node for a transfer slot and wait until it is granted.""" + tr = os.urandom(16).hex() + self.ch.send({"type": "transfer_open", "v": "0.1", "tr": tr, "kind": kind, + "bytes": size, "chunks": chunks}) + while (await self.ch.recv("transfer_state"))["state"] != "granted": + print("Waiting for a free transfer slot on the node...") + return tr + + def close_transfer(self, tr: str) -> None: + self.ch.send({"type": "transfer_close", "v": "0.1", "tr": tr, "reason": "done"}) + + async def close(self) -> None: + await self.pc.close() + + +async def connect(hub_url: str, username: str, passphrase: str, + group_name: str, node_id: str = "") -> Session: + async with httpx.AsyncClient(base_url=hub_url, timeout=30) as hub: + + # 1. Sign in to the hub. + r = await hub.post("/v1/users/login", json={ + "username": username, "auth_key": auth_key(username, passphrase)}) + if r.status_code != 200: + sys.exit(f"Sign-in refused: {r.text}") + login = r.json() + auth = {"Authorization": f"Bearer {login['access_token']}"} + user_id = (await hub.get("/v1/users/me", headers=auth)).json()["user_id"] + master = bundle_master_key(username, passphrase, user_id, login["bundle_pepper"]) + + # 2. Find the group, and a node that serves it. + groups = (await hub.get("/v1/groups/mine", headers=auth)).json()["groups"] + group = next((g for g in groups if g["name"] == group_name), None) + if group is None: + sys.exit(f"You are not a member of a group named {group_name!r}") + group_id = group["id"] + nodes = (await hub.get(f"/v1/groups/{group_id}/nodes", headers=auth)).json()["nodes"] + node = next((n for n in nodes if not node_id or n["node_id"] == node_id), None) + if node is None: + sys.exit("No node serving this group is online") + + # 3. Open a DataChannel to the node. The hub relays the offer and the + # answer, and nothing after that. + pc = RTCPeerConnection(RTCConfiguration( + iceServers=[RTCIceServer(urls="stun:stun.l.google.com:19302")])) + dc = pc.createDataChannel("mnp", ordered=True) + ch = Channel(dc) + opened = asyncio.Event() + dc.on("open", opened.set) + await pc.setLocalDescription(await pc.createOffer()) + offer_sdp = pc.localDescription.sdp + r = await hub.post(f"/v1/nodes/{node['node_id']}/webrtc/offer", headers=auth, + json={"sdp": offer_sdp, "ice_candidates": []}) + if r.status_code != 200: + sys.exit(f"The node did not answer: {r.text}") + answer_sdp = r.json()["sdp"] + await pc.setRemoteDescription(RTCSessionDescription(sdp=answer_sdp, type="answer")) + await asyncio.wait_for(opened.wait(), timeout=30) + binding = webrtc_binding(dtls_fingerprint(offer_sdp), dtls_fingerprint(answer_sdp)) + + # 4. Handshake. The node gets a short-lived token for this group only, + # never the hub session token. + r = await hub.post("/v1/nodes/mnp-token", headers=auth, + json={"node_pk": node["pk_node"], "group_id": group_id}) + mnp_token = r.json()["mnp_token"] + + nonce_c = os.urandom(32) + ch.send({"type": "handshake", "v": MNP_VERSION, "v_min": MNP_MIN_SUPPORTED, + "token": mnp_token, "group_id": group_id, "nonce": b64(nonce_c)}) + challenge = await ch.recv("handshake_challenge") + check_version(challenge["v"], challenge["v_min"]) + nonce_s = unb64(challenge["nonce"]) + node_pk = challenge["node_pk"] + # The node signs its challenge over this connection: node_pk is proved here. + Ed25519PublicKey.from_public_bytes(unb64(node_pk)).verify( + unb64(challenge["sig"]), challenge_transcript(group_id, nonce_c, nonce_s, binding)) + + # 5. Your identity keys for this node, which it keeps sealed for you. + ch.send({"type": "keypair_bundle_fetch", "v": "0.1"}) + kp = await ch.recv("keypair_bundle_resp") + if not kp.get("found"): + sys.exit("This node keeps no copy of your keys. Turn on Browser access in " + "the application's settings, or open the group once in a browser.") + sk_ed, sk_x = open_identity(kp["bundle_enc"], master, user_id, node_pk) + + # 6. The group key. The node recognises your identity and wraps the key for it. + pk_ed = pk_to_b64(sk_ed.public_key()) + pk_x = b64(sk_x.public_key().public_bytes_raw()) + ts = int(time.time()) + ch.send({"type": "join_request", "v": "0.1", "group_id": group_id, + "pk_ed25519": pk_ed, "pk_x25519": pk_x, "code": "", "ts": ts, + "sig": b64(sk_ed.sign(join_transcript( + node_pk_b64=node_pk, group_id=group_id, user_id=user_id, + pk_ed25519_b64=pk_ed, pk_x25519_b64=pk_x, nonce_node=nonce_s, ts=ts)))}) + joined = await ch.recv("join_result") + if not joined.get("ok") or not joined.get("gek"): + sys.exit(f"The node did not give the group key: {joined.get('reason')}") + gek = unwrap_gek_aes(joined, sk_x.private_bytes_raw(), + sk_x.public_key().public_bytes_raw()) + + # 7. Prove the group key, then check that the node proves it too. + ch.send({"type": "handshake_response", "v": "0.1", "proof": b64(make_proof( + gek, "client", group_id, nonce_c, nonce_s, binding))}) + ack = await ch.recv("handshake_ack") + if not verify_proof(gek, unb64(ack["proof"]), "node", group_id, nonce_c, nonce_s, binding): + sys.exit("The node does not hold the group key") + Ed25519PublicKey.from_public_bytes(unb64(ack["node_pk"])).verify( + unb64(ack["sig"]), handshake_transcript("node", group_id, nonce_c, nonce_s, binding)) + if ack["node_pk"] != node_pk: + sys.exit("The node changed identity during the handshake") + unseal(gek, PURPOSE_ACK, "handshake_ack", group_id, ack) + + return Session(pc, ch, group_id, gek) diff --git a/examples/upload.py b/examples/upload.py new file mode 100644 index 0000000..518b5ec --- /dev/null +++ b/examples/upload.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Upload one file to a MeshBay group, as a member, without a browser.""" + +import asyncio +import math +import os + +from meshbay_common.groupbox import PURPOSE_UPLOAD, unseal +from meshbay_common.protocol import file_upload_wire +from meshbay_session import connect + +HUB = "https://meshbay.org" +USERNAME = "alice" +PASSPHRASE = "your passphrase" +GROUP = "Family Photos" # the group's name, as shown in the sidebar +NODE_ID = "" # empty: the first node online for this group +LOCAL_FILE = "beach.jpg" # the file to send +DEST_DIR = "shared/2024" # root name, then folders; the root must be writable + +CHUNK_SIZE = 48 * 1024 # what fits in one DataChannel message +WINDOW = 32 # chunks sent ahead of the node's acks + + +async def main() -> None: + session = await connect(HUB, USERNAME, PASSPHRASE, GROUP, NODE_ID) + + filename = os.path.basename(LOCAL_FILE) + size = os.path.getsize(LOCAL_FILE) + chunks = max(1, math.ceil(size / CHUNK_SIZE)) + tr = await session.open_transfer("upload", size, chunks) + upload_id = os.urandom(16).hex() + + # The file name, the folder and the bytes are sealed under the group key. + # Only the upload id and the chunk numbers stay in clear. + acked = 0 + ack = {} + with open(LOCAL_FILE, "rb") as f: + for i in range(chunks): + if i - acked >= WINDOW: + ack = await session.ch.recv("file_upload_ack") + acked += 1 + msg = file_upload_wire(session.gek, session.group_id, upload_id=upload_id, + chunk_index=i, total_chunks=chunks, filename=filename, + data=f.read(CHUNK_SIZE), dir=DEST_DIR) + session.ch.send({**msg, "tr": tr}) + print(f"\r{i + 1}/{chunks} chunks", end="", flush=True) + while acked < chunks: + ack = await session.ch.recv("file_upload_ack") + acked += 1 + session.close_transfer(tr) + + # The node never overwrites: on a name clash it picks a free one. + done = unseal(session.gek, PURPOSE_UPLOAD, "file_upload_ack", session.group_id, ack) + print(f"\nStored as {done['dir']}/{done['stored_as']} ({size} bytes)") + await session.close() + + +if __name__ == "__main__": + asyncio.run(main()) |