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.py144
1 files changed, 120 insertions, 24 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/session.py b/packages/meshbay-cli/src/meshbay_cli/client/session.py
index 41bf0d6..d10acc8 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/session.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/session.py
@@ -31,6 +31,7 @@ import logging
import os
import struct
import time
+from dataclasses import dataclass
from datetime import UTC, datetime
import msgpack
@@ -64,11 +65,14 @@ from meshbay_common.join import join_transcript
from ..errors import NODE, NODE_KEY, VERSION, CliError
from .account import Account
from .keys import (
+ LEGACY_MAGIC,
BundleError,
Identity,
b64,
bundle_is_current,
open_bundle,
+ open_recovery,
+ opens_under,
seal_bundle,
unb64,
)
@@ -94,6 +98,25 @@ class NodeRefusal(CliError):
self.refusal_code = code
+class NothingHere(CliError):
+ """A node that holds no keys of this account: nothing to carry across."""
+
+ def __init__(self):
+ super().__init__(NODE, "this account has no keys on this node")
+
+
+@dataclass
+class Rewrap:
+ """
+ A connection made only to carry this account's copy on the node across a
+ passphrase change or a reset: it never makes new keys, never asks for a
+ device approval, and reads the recovery copy with `recovery` (the recovery
+ key) where the passphrase copy does not open.
+ """
+
+ recovery: bytes | None = None
+
+
class NodeKeyChanged(CliError):
def __init__(self, node_id: str, pinned: str, now: str):
super().__init__(
@@ -214,6 +237,9 @@ class GroupSession:
# paired with the node — the authority to delete anyone's file. Not the
# hub's word on who owns the group (docs/MESHBAY_DESIGN.md §3.3).
self.role = role
+ # What the node keeps of this account's keys, fetched by a Rewrap
+ # connection: `bundle_enc` and `bundle_enc_recovery`, or empty.
+ self.held: dict = {}
async def index(self) -> dict:
"""`{version, entries, dirs, roots}`, opened under the group key."""
@@ -230,7 +256,8 @@ class GroupSession:
async def connect(account: Account, group_id: str, node: dict,
- join_code: str | None = None) -> GroupSession:
+ join_code: str | None = None, *,
+ rewrap: Rewrap | 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
@@ -262,7 +289,7 @@ async def connect(account: Account, group_id: str, node: dict,
"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, join_code)
+ mnp_token, binding, join_code, rewrap)
except BaseException:
with contextlib.suppress(Exception):
await pc.close()
@@ -271,7 +298,7 @@ async def connect(account: Account, group_id: str, node: dict,
async def _handshake(account: Account, pc, ch: Channel, group_id: str, node_id: str,
listed_pk: str, mnp_token: str, binding: bytes,
- join_code: str | None) -> GroupSession:
+ join_code: str | None, rewrap: Rewrap | None = None) -> GroupSession:
nonce_c = os.urandom(32)
challenge = await ch.request(
{"type": "handshake", "v": MNP_VERSION, "v_min": MNP_MIN_SUPPORTED,
@@ -293,8 +320,14 @@ 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, bundle = await _identity(account, ch, node_pk)
- if bundle is None and account.browser_access:
+ held: dict = {}
+ if rewrap is not None:
+ held = await _fetch_held(ch)
+ identity, source = await _rewrap_identity(account, node_pk, held, rewrap)
+ bundle = held.get("bundle_enc") or ""
+ else:
+ identity, source, bundle = await _identity(account, ch, node_pk)
+ if bundle is None and (account.browser_access or account.previous_masters):
# 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.
@@ -314,7 +347,7 @@ async def _handshake(account: Account, pc, ch: Channel, group_id: str, node_id:
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":
+ if reason == "unknown_device" and source != "bundle" and rewrap is None:
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)
@@ -354,16 +387,59 @@ 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)
- await _settle_bundle(account, ch, identity, node_pk, bundle)
- return GroupSession(pc, ch, group_id, node_id, node_pk, gek, config,
- identity, nonce_s, str(joined.get("role") or ""))
+ if rewrap is None:
+ await _settle_bundle(account, ch, identity, node_pk, bundle)
+ session = GroupSession(pc, ch, group_id, node_id, node_pk, gek, config,
+ identity, nonce_s, str(joined.get("role") or ""))
+ session.held = held
+ return session
-async def _fetch_bundle(ch: Channel) -> str:
- """The node's copy of this account's keys, or "" when it keeps none."""
+async def _fetch_held(ch: Channel) -> dict:
+ """The node's copies of this account's keys: `bundle_enc`, sealed under `M`,
+ and `bundle_enc_recovery`, under the recovery key — {} 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 ""
+ if not found.get("found"):
+ return {}
+ return {k: str(found[k]) for k in ("bundle_enc", "bundle_enc_recovery") if found.get(k)}
+
+
+async def _fetch_bundle(ch: Channel) -> str:
+ """The node's copy of this account's keys, or "" when it keeps none."""
+ return (await _fetch_held(ch)).get("bundle_enc", "")
+
+
+def open_held(account: Account, held: dict, node_pk: str,
+ recovery: bytes | None) -> dict:
+ """The keys in the node's copy: under this session's `M` (or the legacy key),
+ else under the recovery key. BundleError when none opens it."""
+ try:
+ return open_bundle(held.get("bundle_enc", ""), master=account.master,
+ legacy=account.legacy, user_id=account.user_id,
+ node_pk=node_pk)
+ except BundleError:
+ if recovery is None or not held.get("bundle_enc_recovery"):
+ raise
+ return open_recovery(held["bundle_enc_recovery"], rkey=recovery,
+ user_id=account.user_id, node_pk=node_pk)
+
+
+async def _rewrap_identity(account: Account, node_pk: str, held: dict,
+ rewrap: Rewrap) -> tuple[Identity, str]:
+ """The keys to join with on a Rewrap connection: kept here, or the node's
+ copy opened — never new ones, and never a device request."""
+ kept = account.identity(node_pk)
+ if kept is not None:
+ return kept, "kept"
+ if not held.get("bundle_enc") and not held.get("bundle_enc_recovery"):
+ raise NothingHere()
+ try:
+ identity = Identity.from_pkcs8(open_held(account, held, node_pk, rewrap.recovery))
+ except BundleError as e:
+ raise CliError(NODE, str(e)) from e
+ await account.keep_identity(node_pk, identity)
+ return identity, "bundle"
async def _identity(account: Account, ch: Channel,
@@ -397,31 +473,51 @@ async def _identity(account: Account, ch: Channel,
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.
+ What to leave on this node of the account's keys.
+
+ A copy sealed under a passphrase this machine changed away from is sealed
+ again, same keys, under the current `M` — the node was out of reach when the
+ passphrase changed (docs/MESHBAY_DESIGN.md §3.6). Then what `browser-access`
+ says (spec §2.12): `on`, a copy of this machine's keys where the node holds
+ none, or only the older `MBK2`; `off`, the node's copy withdrawn; unset,
+ nothing. A copy in `MBK3` that opens under no key this machine knows is left
+ as it is: sealed under a passphrase set since, elsewhere, it is the one the
+ browsers open — rewriting it under the old one would lock them out. A
+ failure costs the browsers their copy, never this session.
"""
setting = account.browser_access
- if not setting or bundle is None:
+ if bundle is None or not account.master:
return
try:
+ if bundle and setting != "off":
+ earlier = next((keys for m in account.previous_masters
+ if (keys := opens_under(bundle, m, user_id=account.user_id,
+ node_pk=node_pk)) is not None), None)
+ if earlier is not None:
+ await _store(account, ch, earlier, node_pk)
+ return
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(
+ elif setting == "on" and (not bundle or unb64(bundle)[:4] == LEGACY_MAGIC):
+ await _store(account, ch, identity.to_pkcs8(), node_pk)
+ elif setting == "on" 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")
+ log.warning("The node's copy of your keys is sealed under a passphrase this "
+ "machine does not know — changed elsewhere? It is left as it is; "
+ "run `meshbay-cli login` to sign in with the current one.")
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)
+async def _store(account: Account, ch: Channel, keys: dict, node_pk: str) -> None:
+ sealed = seal_bundle(keys, 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")
+
+
# What a refused join means, for the person reading it.
JOIN_REFUSALS = {
"code_required": "this node has not admitted you yet; it needs the code of your "