From 6f68bb6c1970e26de1f66c073ea5d7fecd21840d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 15 Oct 2026 00:56:04 +0200 Subject: feat(cli): invitations, join, decline, leave, devices, browser access join accepts an invitation or redeems a link pasted as sent, then has the node admit a key pair made here, with the invitation's code (a link's code goes only to the node it names; one for a node that is away is kept for the next connection). decline, leave and devices revoke ask first, and refuse without --yes where there is no terminal. devices approve countersigns the one waiting request a code matches, none when several do. browser-access is the application's setting, kept per machine and never enabled from the hub's mirror: on leaves a copy sealed under M on each node, off withdraws it, unset touches nothing. A key pair made for a first contact is now kept. Co-Authored-By: Claude Opus 5.5 --- .../meshbay-cli/src/meshbay_cli/client/keys.py | 33 ++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) (limited to 'packages/meshbay-cli/src/meshbay_cli/client/keys.py') diff --git a/packages/meshbay-cli/src/meshbay_cli/client/keys.py b/packages/meshbay-cli/src/meshbay_cli/client/keys.py index faca01e..ecf44a5 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/keys.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/keys.py @@ -10,6 +10,7 @@ look like an error — it looks like an account nobody can open. import base64 import hashlib import json +import os from dataclasses import dataclass from argon2.low_level import Type, hash_secret_raw @@ -77,8 +78,9 @@ def open_bundle(bundle_b64: str, *, master: bytes | None, legacy: bytes | None, `MBK3` opens under the node's key derived from `M`, with the account and the node as associated data. `MBK2` opens under `A` alone; it is read so a member - is not locked out of a node still holding one, and never rewritten here — - the application or a browser replaces it. Anything older is refused by name. + is not locked out of a node still holding one, and never written — an `MBK3` + replaces it where this machine leaves bundles (`browser-access on`). Anything + older is refused by name. """ try: raw = unb64(bundle_b64) @@ -106,6 +108,33 @@ def open_bundle(bundle_b64: str, *, master: bytes | None, legacy: bytes | None, return json.loads(plain) +def seal_bundle(keys: dict, *, master: bytes, pepper_version: int, user_id: str, + node_pk: str) -> str: + """`{skEd, skX}` sealed as `MBK3`, as `keyring.js` `seal` writes it: the magic, + the pepper version, a 96-bit nonce, then AES-GCM under the node's bundle key + with the account and the node as associated data.""" + nonce = os.urandom(12) + aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode() + plain = json.dumps({"skEd": keys["skEd"], "skX": keys["skX"]}).encode() + ct = AESGCM(node_bundle_key(master, node_pk)).encrypt(nonce, plain, aad) + return b64(BUNDLE_MAGIC + bytes([pepper_version & 0xFF]) + nonce + ct) + + +def bundle_is_current(bundle_b64: str, *, master: bytes, user_id: str, + node_pk: str) -> bool: + """Whether a bundle is `MBK3` and opens under this `M`: one sealed under an + earlier passphrase, or `MBK2`, is to be sealed again.""" + try: + raw = unb64(bundle_b64) + if raw[:4] != BUNDLE_MAGIC: + return False + aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode() + AESGCM(node_bundle_key(master, node_pk)).decrypt(raw[5:17], raw[17:], aad) + return True + except Exception: + return False + + @dataclass class Identity: """One account's keys on one node.""" -- cgit v1.2.3