aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client/session.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client/session.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/session.py133
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",