summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-05 10:16:28 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-05 10:16:28 +0200
commit41d015137d6d9c097e462cd0662e855c3253d001 (patch)
treee1e5a4e6f1415509c031e079c2b753591975ed05
parentfe64864e8cfd2cca3a70ae375a3ea16d537ae6be (diff)
downloadmeshbay-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>
-rw-r--r--examples/README.md38
-rw-r--r--examples/create_group.py80
-rwxr-xr-xexamples/download.py48
-rw-r--r--examples/list_groups.py31
-rw-r--r--examples/meshbay_session.py251
-rw-r--r--examples/upload.py59
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())