diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-15 00:56:04 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-15 00:56:04 +0200 |
| commit | 6f68bb6c1970e26de1f66c073ea5d7fecd21840d (patch) | |
| tree | 859c74db5819f1d49046e71a53e12afe5eb4193a /packages/meshbay-cli/src/meshbay_cli/client/session.py | |
| parent | 16d92c96bde3a65fc54a93b4662df30cafc831e4 (diff) | |
| download | meshbay-6f68bb6c1970e26de1f66c073ea5d7fecd21840d.tar.gz | |
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client/session.py')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/client/session.py | 133 |
1 files changed, 105 insertions, 28 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/session.py b/packages/meshbay-cli/src/meshbay_cli/client/session.py index 1ea398a..d5264a2 100644 --- a/packages/meshbay-cli/src/meshbay_cli/client/session.py +++ b/packages/meshbay-cli/src/meshbay_cli/client/session.py @@ -9,12 +9,15 @@ from the handshake on goes straight to the node, sealed under the group key 2. the DataChannel, and the channel binding from both DTLS fingerprints; 3. the handshake: the node signs its challenge with the key it announces, and that key must be the one pinned for this node, if any (§5.2, TOFU); -4. this account's identity on the node — kept from an earlier run, or opened - from the bundle the node keeps for browsers; -5. the group key, wrapped by the node for that identity (`join_request`); +4. this account's identity on the node — kept from an earlier run, opened + from the bundle the node keeps for browsers, or new (an invitation's code, + or device linking); +5. the group key, wrapped by the node for that identity (`join_request`, with + the invitation's code when there is one); 6. the mutual proof, the ack verified and unsealed — a payload that does not open ends the session, never reads as an empty group (§4.4); -7. `device_hello`, so the node knows which of the account's devices this is. +7. `device_hello`, so the node knows which of the account's devices this is; +8. what `browser-access` says to leave on the node: a sealed copy, or none. Every request carries a `req_id` and its reply is matched on it (§5.3): matching by arrival order is a guess that fails silently, and against the unrelated @@ -60,7 +63,15 @@ from meshbay_common.join import join_transcript from ..errors import NODE, NODE_KEY, VERSION, CliError from .account import Account -from .keys import BundleError, Identity, b64, open_bundle, unb64 +from .keys import ( + BundleError, + Identity, + b64, + bundle_is_current, + open_bundle, + seal_bundle, + unb64, +) log = logging.getLogger(__name__) @@ -185,7 +196,8 @@ class GroupSession: """An authenticated connection to one node, for one group.""" def __init__(self, pc: RTCPeerConnection, ch: Channel, group_id: str, - node_id: str, node_pk: str, gek: bytes, ack: dict): + node_id: str, node_pk: str, gek: bytes, ack: dict, + identity: Identity | None = None, nonce_node: bytes = b""): self.pc = pc self.ch = ch self.group_id = group_id @@ -193,6 +205,10 @@ class GroupSession: self.node_pk = node_pk self.gek = gek self.ack = ack + # What a device message is signed with: this machine's key on the node, + # and the nonce that ties a signature to this connection. + self.identity = identity + self.nonce_node = nonce_node async def index(self) -> dict: """`{version, entries, dirs, roots}`, opened under the group key.""" @@ -208,8 +224,13 @@ class GroupSession: await self.pc.close() -async def connect(account: Account, group_id: str, node: dict) -> GroupSession: - """Open a session with `node` (a `/v1/groups/{id}/nodes` row) for `group_id`.""" +async def connect(account: Account, group_id: str, node: dict, + join_code: str | None = None) -> GroupSession: + """ + Open a session with `node` (a `/v1/groups/{id}/nodes` row) for `group_id`. + `join_code`: an invitation's code, for a node that has not admitted this + account yet; otherwise one kept for this group and node is sent. + """ node_id, listed_pk = node["node_id"], node["pk_node"] pinned = account.pin(node_id) if pinned and pinned != listed_pk: @@ -236,7 +257,7 @@ async def connect(account: Account, group_id: str, node: dict) -> GroupSession: "here).") from None binding = webrtc_binding(_dtls_fingerprint(offer_sdp), _dtls_fingerprint(answer_sdp)) return await _handshake(account, pc, ch, group_id, node_id, listed_pk, - mnp_token, binding) + mnp_token, binding, join_code) except BaseException: with contextlib.suppress(Exception): await pc.close() @@ -244,7 +265,8 @@ async def connect(account: Account, group_id: str, node: dict) -> GroupSession: async def _handshake(account: Account, pc, ch: Channel, group_id: str, node_id: str, - listed_pk: str, mnp_token: str, binding: bytes) -> GroupSession: + listed_pk: str, mnp_token: str, binding: bytes, + join_code: str | None) -> GroupSession: nonce_c = os.urandom(32) challenge = await ch.request( {"type": "handshake", "v": MNP_VERSION, "v_min": MNP_MIN_SUPPORTED, @@ -266,24 +288,39 @@ async def _handshake(account: Account, pc, ch: Channel, group_id: str, node_id: raise CliError(NODE, "The node's challenge is not signed by the key it " "announces.") from e - identity, source = await _identity(account, ch, node_pk) + identity, source, bundle = await _identity(account, ch, node_pk) + if bundle is None and account.browser_access: + # Whether the node holds a copy, and a current one, decides what the + # setting does after the proof. Asked here: the fetch is a pre-proof + # message, and the window allows it. + bundle = await _fetch_bundle(ch) + code = join_code or account.join_code(group_id, node_pk) or "" ts = int(time.time()) joined = await ch.request({ "type": "join_request", "v": MNP_VERSION, "group_id": group_id, - "pk_ed25519": identity.pk_ed, "pk_x25519": identity.pk_x, "code": "", "ts": ts, + "pk_ed25519": identity.pk_ed, "pk_x25519": identity.pk_x, "code": code, "ts": ts, "sig": b64(identity.sk_ed.sign(join_transcript( node_pk_b64=node_pk, group_id=group_id, user_id=account.user_id, pk_ed25519_b64=identity.pk_ed, pk_x25519_b64=identity.pk_x, nonce_node=nonce_s, ts=ts)))}, "join_result") if not joined.get("ok") or not joined.get("gek"): reason = str(joined.get("reason") or "") + if reason == "code_invalid": + # Spent, cancelled or mistyped: it will not work on another try. + await account.drop_join_code(group_id) if reason == "unknown_device" and source != "bundle": await _link(account, ch, node_pk, nonce_s, identity, source) raise NodeRefusal(JOIN_REFUSALS.get(reason, reason or "the group key was not given"), reason) if source == "pending": await account.adopt_pending(node_pk) + elif source == "new": + # Admitted with a code, or by an open group: these keys are now pinned + # there, and an identity not kept would strand the account on the node. + await account.keep_identity(node_pk, identity) + if code: + await account.drop_join_code(group_id) gek = unwrap_gek_aes(joined, identity.sk_x.private_bytes_raw(), identity.sk_x.public_key().public_bytes_raw()) @@ -312,40 +349,80 @@ async def _handshake(account: Account, pc, ch: Channel, group_id: str, node_id: await account.set_pin(node_id, node_pk) await _device_hello(account, ch, identity, node_pk, group_id, nonce_s) - return GroupSession(pc, ch, group_id, node_id, node_pk, gek, config) + await _settle_bundle(account, ch, identity, node_pk, bundle) + return GroupSession(pc, ch, group_id, node_id, node_pk, gek, config, + identity, nonce_s) + + +async def _fetch_bundle(ch: Channel) -> str: + """The node's copy of this account's keys, or "" when it keeps none.""" + found = await ch.request({"type": "keypair_bundle_fetch", "v": MNP_VERSION}, + "keypair_bundle_resp") + return str(found.get("bundle_enc") or "") if found.get("found") else "" -async def _identity(account: Account, ch: Channel, node_pk: str) -> tuple[Identity, str]: +async def _identity(account: Account, ch: Channel, + node_pk: str) -> tuple[Identity, str, str | None]: """ - This account's keys on this node, and where they came from: kept from an - earlier run, waiting for a device approval, opened from the copy the node - keeps for browsers — or, failing all three, new, for device linking. + This account's keys on this node, where they came from, and the node's bundle + when it was fetched (None: not asked). Kept from an earlier run, waiting for + a device approval, opened from the copy the node keeps for browsers — or, + failing all three, new: for an invitation's code, or for device linking. """ kept = account.identity(node_pk) if kept is not None: - return kept, "kept" + return kept, "kept", None pending = account.pending_identity(node_pk) if pending is not None: - return pending.identity, "pending" - found = await ch.request({"type": "keypair_bundle_fetch", "v": MNP_VERSION}, - "keypair_bundle_resp") - if not found.get("found"): - return Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()), "new" + return pending.identity, "pending", None + bundle = await _fetch_bundle(ch) + if not bundle: + return (Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()), + "new", bundle) try: - keys = open_bundle(found["bundle_enc"], master=account.master, + keys = open_bundle(bundle, master=account.master, legacy=account.legacy, user_id=account.user_id, node_pk=node_pk) identity = Identity.from_pkcs8(keys) except BundleError as e: raise CliError(NODE, str(e)) from e await account.keep_identity(node_pk, identity) - return identity, "bundle" + return identity, "bundle", bundle + + +async def _settle_bundle(account: Account, ch: Channel, identity: Identity, node_pk: str, + bundle: str | None) -> None: + """ + What `browser-access` says to leave on this node (spec §2.12): `on`, a copy + sealed under the current `M` when the node holds none or an outdated one — + a current copy is left alone, whichever device sealed it, so two clients do + not take turns rewriting it; `off`, the node's copy withdrawn; unset, + nothing. A failure costs the browsers their copy, never this session. + """ + setting = account.browser_access + if not setting or bundle is None: + return + try: + if setting == "off" and bundle: + await ch.request({"type": "keypair_bundle_delete", "v": MNP_VERSION}, "ack") + elif setting == "on" and account.master and not bundle_is_current( + bundle, master=account.master, user_id=account.user_id, node_pk=node_pk): + sealed = seal_bundle(identity.to_pkcs8(), master=account.master, + pepper_version=account.pepper_version, + user_id=account.user_id, node_pk=node_pk) + await ch.request({"type": "keypair_bundle_store", "v": MNP_VERSION, + "bundle_enc": sealed}, "ack") + except CliError as e: + log.warning("Browser access is %s here, but the node's copy of your keys was " + "not %s: %s", setting, "withdrawn" if setting == "off" else "stored", + e.message) # What a refused join means, for the person reading it. JOIN_REFUSALS = { - "code_required": "this node has not admitted you yet. Accept the invitation in the " - "MeshBay application and enter its code there; the command line " - "does not join groups", + "code_required": "this node has not admitted you yet; it needs the code of your " + "invitation: meshbay-cli join GROUP --code CODE", + "code_invalid": "the invitation's code was refused: already used, cancelled, or " + "mistyped. Ask the group's owner for a new one", "unknown_device": "the key this node holds for you is not one of your devices there " "any more; ask its operator, or approve this machine from a device " "already linked", |