aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/tests/fake_node.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 01:35:09 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 01:35:09 +0200
commit47583c5e405f3011a526df73cc9485c46598f214 (patch)
tree90fafd2e7d3978071b38402253cf7eb70717ec7f /packages/meshbay-cli/tests/fake_node.py
parentced66b96f898abccec2526650ab356eb6b82f066 (diff)
downloadmeshbay-47583c5e405f3011a526df73cc9485c46598f214.tar.gz
feat(cli): chat — read a group's last messages, post one
chat GROUP prints the last ten messages, oldest first (-N, as head -N, or -n N for another count, paging back past the node's 200), and -m posts one (- reads it from stdin). The application's construction: every epoch key from chat_keys_req, a post sealed under the current epoch through the device subkey and signed with this machine's key on the node, the sender's name inside the envelope; nothing sent past the node's ciphertext bound. A stored message is read signature first, then key, then content, and one that fails is shown as unreadable, never dropped. Not done here: the Tier 2 roster check on the signing device. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/tests/fake_node.py')
-rw-r--r--packages/meshbay-cli/tests/fake_node.py61
1 files changed, 59 insertions, 2 deletions
diff --git a/packages/meshbay-cli/tests/fake_node.py b/packages/meshbay-cli/tests/fake_node.py
index 73d6f72..f5fe631 100644
--- a/packages/meshbay-cli/tests/fake_node.py
+++ b/packages/meshbay-cli/tests/fake_node.py
@@ -22,13 +22,20 @@ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from fake_hub import USER_ID
from meshbay_cli.client.keys import Identity
from meshbay_cli.client.session import GroupSession, NodeRefusal
+from meshbay_common import chatbox
from meshbay_common.adminop import OP_FILE_DELETE, admin_transcript
from meshbay_common.device import (
device_add_transcript,
device_code_hash,
device_revoke_transcript,
)
-from meshbay_common.groupbox import PURPOSE_INDEX, PURPOSE_UPLOAD, seal, unseal
+from meshbay_common.groupbox import (
+ PURPOSE_CHAT_KEYS,
+ PURPOSE_INDEX,
+ PURPOSE_UPLOAD,
+ seal,
+ unseal,
+)
from meshbay_common.protocol import UPLOAD_PROBE_INDEX, file_upload_ack_wire
from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes
@@ -59,7 +66,9 @@ class FakeNode:
"pinned_via": "code"}]
self.requests: list[dict] = []
self.role = "" # "operator": this key was paired here
- self.ack: dict = {} # the sealed configuration, opened
+ self.ack: dict = {"chat_epoch": 1} # the sealed configuration, opened
+ self.chat_epochs = {1: secrets.token_bytes(32)}
+ self.chat: list[dict] = [] # stored rows, oldest first
self.admin_ops: dict[str, dict] = {}
def add(self, path: str, name: str, data: bytes, uploader_id: str = "") -> dict:
@@ -77,6 +86,25 @@ class FakeNode:
return GroupSession(None, FakeChannel(self), GROUP_ID, "node-1", NODE_PK, self.gek,
dict(self.ack), self.identity, self.nonce, self.role)
+ def say(self, text: str, *, sender: str = "bob-test", epoch: int = 1,
+ forge: bool = False) -> None:
+ """Another member's message, sealed and signed as a client does; `forge`
+ signs it with a key other than the device it names."""
+ other = Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate())
+ sealed = chatbox.seal(self.chat_epochs[epoch], GROUP_ID, epoch, other.pk_ed,
+ base64.b64decode(other.pk_ed), other.sk_ed,
+ {"text": text, "sender_name": sender})
+ if forge:
+ sealed["sig"] = Ed25519PrivateKey.generate().sign(b"something else")
+ self._store(base64.b64decode(other.pk_ed), epoch, sealed, "u-" + sender)
+
+ def _store(self, device: bytes, epoch: int, sealed: dict, sender_id: str) -> None:
+ self.chat.append({"id": len(self.chat) + 1, "sender_id": sender_id,
+ "sender_name": "", "timestamp": 1_700_000_000 + len(self.chat),
+ "thread_id": None, "format": 1, "epoch": epoch,
+ "device": device, "nonce": sealed["nonce"], "sig": sealed["sig"],
+ "payload": "", "ct": sealed["ct"]})
+
def request_device(self, code: str) -> Identity:
"""Another machine files a request with `code`, as it would before its proof."""
other = Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate())
@@ -199,6 +227,35 @@ class FakeChannel:
dir=folder)
+ # ── Chat ─────────────────────────────────────────────────────────────────
+
+ def _chat_keys_req(self, msg):
+ n = self.node
+ payload = {"epochs": [{"epoch": e, "key": k} for e, k in sorted(n.chat_epochs.items())],
+ "current": max(n.chat_epochs)}
+ return {"type": "chat_keys_resp", "group_id": GROUP_ID,
+ **seal(n.gek, PURPOSE_CHAT_KEYS, "chat_keys_resp", GROUP_ID, payload)}
+
+ def _chat_hist(self, msg):
+ rows = self.node.chat
+ if msg.get("before"):
+ rows = [r for r in rows if r["id"] < msg["before"]]
+ page = rows[-min(int(msg["limit"]), 200):]
+ return {"type": "chat_hist_resp", "messages": page,
+ "has_more": bool(page) and page[0]["id"] > rows[0]["id"]}
+
+ def _chat_msg(self, msg):
+ n = self.node
+ if bytes(msg["device"]) != base64.b64decode(n.identity.pk_ed):
+ return {"type": "error", "detail": "That is not the device on this connection"}
+ if msg["epoch"] != max(n.chat_epochs) or msg["format"] != 1:
+ return {"type": "error", "detail": "stale epoch or plaintext"}
+ if not chatbox.verify(GROUP_ID, msg["epoch"], bytes(msg["device"]), msg["nonce"],
+ msg["ct"], msg["sig"]):
+ return {"type": "error", "detail": "bad signature"}
+ n._store(bytes(msg["device"]), msg["epoch"], msg, USER_ID)
+ return {"type": "ack"}
+
# ── Deleting: a challenge, signed with this machine's key ──────────────
def _file_delete(self, msg):