aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 00:56:04 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 00:56:04 +0200
commit6f68bb6c1970e26de1f66c073ea5d7fecd21840d (patch)
tree859c74db5819f1d49046e71a53e12afe5eb4193a /packages/meshbay-cli/src/meshbay_cli/client
parent16d92c96bde3a65fc54a93b4662df30cafc831e4 (diff)
downloadmeshbay-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')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/account.py35
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/devices.py101
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/groups.py30
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/hub.py41
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/invite.py50
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/keys.py33
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/paths.py9
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/session.py133
8 files changed, 394 insertions, 38 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/account.py b/packages/meshbay-cli/src/meshbay_cli/client/account.py
index d620f6d..9bb85ce 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/account.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/account.py
@@ -163,11 +163,14 @@ class Account:
# A node's key does not depend on who signed in.
"pins": previous.get("pins", {}) if previous.get("hub") == self.hub.url else {},
}
+ if same_account and previous.get("browser_access"):
+ state["browser_access"] = previous["browser_access"]
secrets = {
"access_token": token,
"access_exp": time.time() + int(r.get("expires_in", 0)),
"refresh_token": r["refresh_token"],
"master": b64(master),
+ "pepper_version": int(r.get("bundle_pepper_version") or 1),
"legacy": b64(a),
"identities": self.secrets.get("identities", {}) if same_account else {},
}
@@ -306,6 +309,38 @@ class Account:
secrets.setdefault("identities", {})[node_pk] = p["keys"]
await self.update(move)
+ # An invitation's code waiting for its node: kept when the node was not
+ # reachable at `join`, sent by the next connection to it, dropped once used
+ # or refused. A link's code names its node; an invitation's does not.
+
+ def join_code(self, group_id: str, node_pk: str) -> str | None:
+ c = self.secrets.get("join_codes", {}).get(group_id)
+ if not c or (c.get("node_pk") and c["node_pk"] != node_pk):
+ return None
+ return c["code"]
+
+ async def keep_join_code(self, group_id: str, code: str, node_pk: str | None) -> None:
+ def put(state: dict, secrets: dict) -> None:
+ secrets.setdefault("join_codes", {})[group_id] = {"code": code,
+ "node_pk": node_pk}
+ await self.update(put)
+
+ async def drop_join_code(self, group_id: str) -> None:
+ def drop(state: dict, secrets: dict) -> None:
+ secrets.get("join_codes", {}).pop(group_id, None)
+ if group_id in self.secrets.get("join_codes", {}):
+ await self.update(drop)
+
+ @property
+ def browser_access(self) -> str | None:
+ """This machine's setting: "on", "off", or None (unset — leave the node
+ as it is). Set only by `browser-access`; never from the hub."""
+ return self.state.get("browser_access")
+
+ @property
+ def pepper_version(self) -> int:
+ return int(self.secrets.get("pepper_version") or 1)
+
def pin(self, node_id: str) -> str | None:
return self.state.get("pins", {}).get(node_id)
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/devices.py b/packages/meshbay-cli/src/meshbay_cli/client/devices.py
new file mode 100644
index 0000000..cb30701
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/client/devices.py
@@ -0,0 +1,101 @@
+"""
+This account's devices on one node: what the application's "Your devices on this
+node" does (docs/MESHBAY_DESIGN.md §3.3).
+
+Every message here is sent on an authenticated session and signed by this
+machine's key on the node, over the connection's nonce: the node admits or
+retires a key only on the word of a device it already pinned.
+"""
+
+import time
+
+from meshbay_common import MNP_VERSION
+from meshbay_common.device import (
+ device_add_transcript,
+ device_code_hash,
+ device_revoke_transcript,
+)
+
+from ..errors import NOT_FOUND, USAGE, CliError
+from .account import Account
+from .keys import b64
+from .session import GroupSession
+
+
+def normalize_code(code: str) -> str:
+ """
+ A code as typed, folded onto what was generated — `crypto.js` `normalizeCode`
+ and the node's `roster.normalize_code`: case, dashes and spaces do not count,
+ and the letters left out of the alphabet read as the digits they resemble.
+ """
+ fold = {"I": "1", "L": "1", "O": "0", "U": "V"}
+ return "".join(fold.get(c, c) for c in code.upper() if c not in "- \t")
+
+
+async def list_devices(session: GroupSession) -> dict:
+ """`{devices: [{pk_ed25519, label, pinned_at, pinned_via, is_this_one}], pending}`."""
+ r = await session.ch.request({"type": "device_list", "v": MNP_VERSION},
+ "device_list_result")
+ return {"devices": r.get("devices") or [], "pending": int(r.get("pending") or 0)}
+
+
+async def approve(session: GroupSession, account: Account, code: str) -> str:
+ """
+ Countersign the one pending request this code names; its Ed25519 key.
+
+ The code never leaves this machine: each pending request is hashed with it
+ and must match the hash its device filed. None, or more than one, approves
+ nothing — filing needs only the account's token, so somebody holding the
+ passphrase who saw the code could file their own keys under it.
+ """
+ raw = normalize_code(code)
+ if len(raw) != 8:
+ raise CliError(USAGE, "A linking code has 8 characters, as XXXX-XXXX.")
+ listed = await session.ch.request({"type": "device_lookup", "v": MNP_VERSION},
+ "device_lookup_result")
+ matches = [r for r in listed.get("requests") or []
+ if device_code_hash(raw, r["pk_ed25519"], r["pk_x25519"]) == r["code_hash"]]
+ if not matches:
+ raise CliError(NOT_FOUND, "No device is waiting with that code on this node.")
+ if len(matches) > 1:
+ raise CliError(NOT_FOUND, "More than one device is waiting with that code, so "
+ "none was approved. Get a new code on the device you "
+ "are linking.")
+ match = matches[0]
+ ts = int(time.time())
+ sig = session.identity.sk_ed.sign(device_add_transcript(
+ node_pk_b64=session.node_pk, user_id=account.user_id,
+ pk_ed25519_b64=match["pk_ed25519"], pk_x25519_b64=match["pk_x25519"],
+ nonce_node=session.nonce_node, ts=ts))
+ await session.ch.request({"type": "device_add", "v": MNP_VERSION,
+ "pk_ed25519": match["pk_ed25519"],
+ "pk_x25519": match["pk_x25519"],
+ "code_hash": match["code_hash"], "ts": ts, "sig": b64(sig)},
+ "device_add_ack")
+ return match["pk_ed25519"]
+
+
+def find_device(devices: list[dict], prefix: str) -> dict:
+ """The one device whose key starts with `prefix` (8 characters at least)."""
+ if len(prefix) < 8:
+ raise CliError(USAGE, "Name the device by the first 8 characters of its key "
+ "at least, as `devices list` shows it.")
+ found = [d for d in devices if d["pk_ed25519"].startswith(prefix)]
+ if not found:
+ raise CliError(NOT_FOUND, f"No device of yours on this node has a key starting "
+ f"with {prefix}.")
+ if len(found) > 1:
+ raise CliError(USAGE, f"Several devices have a key starting with {prefix}; "
+ "give more of it.")
+ return found[0]
+
+
+async def revoke(session: GroupSession, account: Account, pk_ed: str) -> None:
+ """Retire a device of this account. The node refuses the last one."""
+ ts = int(time.time())
+ sig = session.identity.sk_ed.sign(device_revoke_transcript(
+ node_pk_b64=session.node_pk, user_id=account.user_id, pk_ed25519_b64=pk_ed,
+ nonce_node=session.nonce_node, ts=ts))
+ await session.ch.request({"type": "device_revoke", "v": MNP_VERSION,
+ "pk_ed25519": pk_ed, "ts": ts, "sig": b64(sig)},
+ "device_add_ack")
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/groups.py b/packages/meshbay-cli/src/meshbay_cli/client/groups.py
index ac738f3..4288c32 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/groups.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/groups.py
@@ -7,12 +7,15 @@ import time
from ..errors import NODE, CliError
from .account import Account
from .paths import handle
-from .session import DeviceLinkPending, GroupSession, NodeKeyChanged, connect
+from .session import DeviceLinkPending, GroupSession, NodeKeyChanged, NodeRefusal, connect
# How often a machine waiting for its approval tries the node again. Each try is
# one offer: well inside what the hub admits per account and per node.
LINK_POLL = 10
+# Join refusals about the account rather than the node: every node gives them.
+ACCOUNT_REFUSALS = frozenset({"code_required", "code_invalid"})
+
log = logging.getLogger(__name__)
@@ -20,22 +23,38 @@ async def my_groups(account: Account) -> list[dict]:
return await account.call(account.hub.my_groups)
-async def open_group(account: Account, group: dict) -> GroupSession:
+async def open_group(account: Account, group: dict, *, join_code: str | None = None,
+ node_pk: str | None = None) -> GroupSession:
"""
A session with a node serving `group`: the pinned one first, then the others
in the hub's order. A node whose key changed stops everything — that is an
alarm, not a reason to quietly try its neighbour.
+
+ `join_code` goes in the join; `node_pk` restricts the tries to that node — an
+ invitation link's code is for the node the link names and no other.
"""
nodes = await account.call(account.hub.group_nodes, group["id"])
+ if node_pk:
+ nodes = [n for n in nodes if n.get("pk_node") == node_pk]
+ if not nodes:
+ raise CliError(NODE, f"The node the invitation names for {handle(group)} "
+ "is not online.")
if not nodes:
raise CliError(NODE, f"No node serving {handle(group)} is online.")
nodes = sorted(nodes, key=lambda n: account.pin(n["node_id"]) is None)
failures = []
for node in nodes:
try:
- return await _connect_linking(account, group, node)
+ return await _connect_linking(account, group, node, join_code)
except (NodeKeyChanged, DeviceLinkPending):
raise
+ except NodeRefusal as e:
+ # A refusal about this account — a code to give — is the same answer
+ # from every node; trying the next would only bury it.
+ if e.refusal_code in ACCOUNT_REFUSALS or len(nodes) == 1:
+ raise
+ log.info("node %s: %s", node["node_id"], e.message)
+ failures.append(f"{node['node_id']}: {e.message}")
except CliError as e:
if e.code != NODE or len(nodes) == 1:
raise
@@ -45,7 +64,8 @@ async def open_group(account: Account, group: dict) -> GroupSession:
+ "\n ".join(failures))
-async def _connect_linking(account: Account, group: dict, node: dict) -> GroupSession:
+async def _connect_linking(account: Account, group: dict, node: dict,
+ join_code: str | None = None) -> GroupSession:
"""
`connect`, and — when this machine is waiting to be approved as a device on
that node — the wait: the code is shown once (`account.on_device_code`), and
@@ -55,7 +75,7 @@ async def _connect_linking(account: Account, group: dict, node: dict) -> GroupSe
shown = False
while True:
try:
- return await connect(account, group["id"], node)
+ return await connect(account, group["id"], node, join_code)
except DeviceLinkPending as pending:
hook = account.on_device_code
wait = hook(group, pending, first=not shown) if hook else False
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/hub.py b/packages/meshbay-cli/src/meshbay_cli/client/hub.py
index c8ce0c1..94d22da 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/hub.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/hub.py
@@ -1,9 +1,10 @@
"""
The hub's API, as a member's client uses it.
-The hub signs in, lists groups and the nodes serving them, mints the token a
-node is shown, and relays the WebRTC offer. Nothing else: no listing, no file
-and no byte of content passes through it (docs/MESHBAY_DESIGN.md §1.3, §8.4).
+The hub signs in, lists groups and the nodes serving them, keeps memberships
+(invitations, links, leaving) and preferences, mints the token a node is shown,
+and relays the WebRTC offer. Nothing else: no listing, no file and no byte of
+content passes through it (docs/MESHBAY_DESIGN.md §1.3, §8.4).
"""
import logging
@@ -147,6 +148,40 @@ class Hub:
raise CliError(NODE, f"The node did not answer the offer: {e.detail}") from e
return r["sdp"]
+ # ── Joining and leaving ──────────────────────────────────────────────────
+
+ async def invitations(self, token: str) -> list[dict]:
+ return (await self._call("GET", "/v1/groups/invitations",
+ token=token))["invitations"]
+
+ async def accept_invitation(self, token: str, group_id: str) -> dict:
+ return await self._call("POST", f"/v1/groups/{group_id}/invitation/accept",
+ token=token)
+
+ async def decline_invitation(self, token: str, group_id: str) -> dict:
+ return await self._call("POST", f"/v1/groups/{group_id}/invitation/decline",
+ token=token)
+
+ async def preview_link(self, token: str, ticket: str) -> dict:
+ return await self._call("POST", "/v1/invite-links/preview", token=token,
+ json={"ticket": ticket})
+
+ async def redeem_link(self, token: str, ticket: str) -> dict:
+ return await self._call("POST", "/v1/invite-links/redeem", token=token,
+ json={"ticket": ticket})
+
+ async def leave_group(self, token: str, group_id: str) -> dict:
+ return await self._call("POST", f"/v1/groups/{group_id}/leave", token=token)
+
+ # ── Preferences ──────────────────────────────────────────────────────────
+
+ async def preferences(self, token: str) -> dict:
+ return await self._call("GET", "/v1/users/me/preferences", token=token)
+
+ async def set_preference(self, token: str, key: str, value: str) -> dict:
+ return await self._call("PUT", f"/v1/users/me/preferences/{key}", token=token,
+ json={"value": value})
+
def _sign_in_refusal(e: HubError) -> CliError:
if e.status == 401:
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/invite.py b/packages/meshbay-cli/src/meshbay_cli/client/invite.py
new file mode 100644
index 0000000..6b26c97
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/client/invite.py
@@ -0,0 +1,50 @@
+"""
+Invitation links: `https://<hub>/#/invite?v=1&g=<group>&t=<ticket>&n=<node key>&c=<CODE>`.
+
+The shape `static/invite-link.js` reads and `api/invite_links.py` writes
+(docs/MESHBAY_DESIGN.md §3.4). Two secrets: the ticket is for the hub, which
+makes the first account to redeem it a member; the code is for the node the
+link names, and goes to no other node and never to the hub. Every field is
+checked for its exact shape, as the application checks it.
+"""
+
+import re
+from dataclasses import dataclass
+from urllib.parse import parse_qs, urlsplit
+
+_GROUP = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
+_TICKET = re.compile(r"^[A-Za-z0-9_-]{22}$")
+_NODE_PK = re.compile(r"^[A-Za-z0-9_-]{43}$")
+_CODE = re.compile(r"^[0-9A-Za-z]{4}-[0-9A-Za-z]{4}$")
+
+
+@dataclass
+class InviteLink:
+ origin: str # the hub the link was made on, '' when only the fragment was given
+ group_id: str
+ ticket: str
+ node_pk: str # standard base64, as the hub lists node keys
+ code: str
+
+
+def node_pk_from_link(n: str) -> str:
+ """The link's URL-safe, unpadded key, as the hub and the transport write it."""
+ b64 = n.replace("-", "+").replace("_", "/")
+ return b64 + "=" * (-len(b64) % 4)
+
+
+def parse_link(text: str) -> InviteLink | None:
+ """The link in `text`, or None when it holds none (or a malformed one)."""
+ s = text.strip()
+ at = s.find("/invite?")
+ if at < 0:
+ return None
+ q = {k: v[0] for k, v in parse_qs(s[at + len("/invite?"):]).items()}
+ g, t, n = q.get("g", ""), q.get("t", ""), q.get("n", "")
+ c = q.get("c", "").upper()
+ if q.get("v") != "1" or not (_GROUP.match(g) and _TICKET.match(t)
+ and _NODE_PK.match(n) and _CODE.match(c)):
+ return None
+ parts = urlsplit(s)
+ origin = f"{parts.scheme}://{parts.netloc}" if parts.scheme and parts.netloc else ""
+ return InviteLink(origin, g, t, node_pk_from_link(n), c)
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."""
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/paths.py b/packages/meshbay-cli/src/meshbay_cli/client/paths.py
index 1086085..67119f9 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/paths.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/paths.py
@@ -50,6 +50,15 @@ def parse(text: str) -> RemotePath | None:
return RemotePath(decoded[0], decoded[1:], from_link)
+def group_arg(text: str) -> str:
+ """A group named on the command line, as `groups` prints it (`%2F` for a `/`),
+ or as a link to it: the name, decoded, with its `@owner` if given."""
+ path = parse(text)
+ if path is None or path.parts:
+ raise CliError(USAGE, f"Name a group, not a path: {text!r}")
+ return path.group
+
+
def encode_name(name: str) -> str:
"""A group or file name as it is typed in a path."""
return name.replace("%", "%25").replace("/", "%2F")
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",