aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/client')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/account.py9
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/hub.py60
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/keys.py69
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/passphrase.py64
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/remove.py40
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/rewrap.py120
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/client/session.py144
7 files changed, 468 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 96483e9..fae1add 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/account.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/account.py
@@ -90,6 +90,13 @@ class Account:
return unb64(m) if m else None
@property
+ def previous_masters(self) -> list[bytes]:
+ """The `M` of passphrases this machine changed away from: a copy a node
+ still holds under one is carried over at the next connection."""
+ current = self.secrets.get("master")
+ return [unb64(m) for m in self.secrets.get("previous_masters", []) if m != current]
+
+ @property
def legacy(self) -> bytes | None:
a = self.secrets.get("legacy")
return unb64(a) if a else None
@@ -175,6 +182,8 @@ class Account:
"pepper_version": int(r.get("bundle_pepper_version") or 1),
"legacy": b64(a),
"identities": self.secrets.get("identities", {}) if same_account else {},
+ "previous_masters": [m for m in self.secrets.get("previous_masters", [])
+ if m != b64(master)] if same_account else [],
}
if remember:
device = (self.secrets.get("device_sk") if same_account else None)
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/hub.py b/packages/meshbay-cli/src/meshbay_cli/client/hub.py
index 94d22da..5b301f8 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/hub.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/hub.py
@@ -120,6 +120,66 @@ class Hub:
async def me(self, token: str) -> dict:
return await self._call("GET", "/v1/users/me", token=token)
+ # ── The account ──────────────────────────────────────────────────────────
+
+ async def captcha(self) -> dict | None:
+ """A proof-of-work challenge, or None when this hub has the check off."""
+ try:
+ return await self._call("GET", "/v1/users/captcha")
+ except HubError as e:
+ if e.status == 404:
+ return None
+ raise
+
+ async def register(self, username: str, email: str, auth_key: str,
+ recovery_key: str | None, captcha_token: str | None) -> dict:
+ body = {"username": username, "email": email, "auth_key": auth_key}
+ if recovery_key:
+ body["recovery_key"] = recovery_key
+ if captcha_token:
+ body["captcha_token"] = captcha_token
+ return await self._call("POST", "/v1/users/register", json=body)
+
+ async def verify_email(self, email: str, code: str) -> dict:
+ return await self._call("POST", "/v1/users/verify-email",
+ json={"email": email, "code": code})
+
+ async def bundle_pepper(self, token: str, auth_key: str) -> dict:
+ return await self._call("POST", "/v1/users/me/bundle-pepper", token=token,
+ json={"auth_key": auth_key})
+
+ async def change_passphrase(self, token: str, old_auth_key: str,
+ new_auth_key: str) -> dict:
+ return await self._call("POST", "/v1/users/password", token=token,
+ json={"old_auth_key": old_auth_key,
+ "new_auth_key": new_auth_key})
+
+ async def reset_request(self, username: str, email: str,
+ captcha_token: str | None) -> dict:
+ body = {"username": username, "email": email}
+ if captcha_token:
+ body["captcha_token"] = captcha_token
+ return await self._call("POST", "/v1/users/password/reset-request", json=body)
+
+ async def reset_passphrase(self, username: str, code: str, new_auth_key: str) -> dict:
+ return await self._call("POST", "/v1/users/password/reset", json={
+ "username": username, "code": code, "new_auth_key": new_auth_key})
+
+ async def change_email(self, token: str, email: str, auth_key: str) -> dict:
+ return await self._call("PATCH", "/v1/users/me", token=token,
+ json={"email": email, "auth_key": auth_key})
+
+ async def verify_email_change(self, token: str, code: str) -> dict:
+ return await self._call("POST", "/v1/users/verify-email-change", token=token,
+ json={"code": code})
+
+ async def revoke_sessions(self, token: str) -> dict:
+ return await self._call("POST", "/v1/users/me/sessions/revoke", token=token)
+
+ async def delete_account(self, token: str, auth_key: str) -> dict:
+ return await self._call("DELETE", "/v1/users/me", token=token,
+ json={"auth_key": auth_key})
+
# ── Groups and nodes ─────────────────────────────────────────────────────
async def my_groups(self, token: str) -> list[dict]:
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/keys.py b/packages/meshbay-cli/src/meshbay_cli/client/keys.py
index ecf44a5..2ac6f2c 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/keys.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/keys.py
@@ -135,6 +135,75 @@ def bundle_is_current(bundle_b64: str, *, master: bytes, user_id: str,
return False
+def opens_under(bundle_b64: str, master: bytes, *, user_id: str,
+ node_pk: str) -> dict | None:
+ """The `{skEd, skX}` of an `MBK3` bundle sealed under `master`, else None."""
+ try:
+ raw = unb64(bundle_b64)
+ if raw[:4] != BUNDLE_MAGIC:
+ return None
+ aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode()
+ return json.loads(AESGCM(node_bundle_key(master, node_pk)).decrypt(
+ raw[5:17], raw[17:], aad))
+ except Exception:
+ return None
+
+
+# ── The recovery key (docs/MESHBAY_DESIGN.md §3.6) ───────────────────────────
+#
+# 32 random bytes `R`, shown as 13 groups of four Base32 characters; the key
+# that seals a recovery copy is HKDF(R, "meshbay:recovery:v1:" + username),
+# used as it is — not per node — with the bundle's associated data and pepper
+# version 0. As `keyderive.js` `generateRecoveryKey`/`deriveRecoveryKey`.
+
+_B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
+
+
+def generate_recovery() -> tuple[bytes, str]:
+ """`R`, and the grouped mnemonic a person keeps."""
+ raw = os.urandom(32)
+ return raw, recovery_mnemonic(raw)
+
+
+def recovery_mnemonic(raw: bytes) -> str:
+ text = base64.b32encode(raw).decode().rstrip("=")
+ return " ".join(text[i:i + 4] for i in range(0, len(text), 4))
+
+
+def recovery_bytes(mnemonic: str) -> bytes:
+ """`R` from its mnemonic, however spaced or cased; ValueError if it is not one."""
+ clean = "".join(c for c in mnemonic.upper() if c.isalnum())
+ if any(c not in _B32 for c in clean):
+ raise ValueError("a recovery key is letters A to Z and digits 2 to 7")
+ raw = base64.b32decode(clean + "=" * (-len(clean) % 8)) if clean else b""
+ if len(raw) < 32:
+ raise ValueError("this recovery key is too short")
+ return raw[:32]
+
+
+def recovery_key(raw: bytes, username: str) -> bytes:
+ return hkdf(raw, f"meshbay:recovery:v1:{username}")
+
+
+def seal_recovery(keys: dict, *, rkey: bytes, user_id: str, node_pk: str) -> str:
+ nonce = os.urandom(12)
+ aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode()
+ plain = json.dumps({"skEd": keys["skEd"], "skX": keys["skX"]}).encode()
+ return b64(BUNDLE_MAGIC + bytes([0]) + nonce + AESGCM(rkey).encrypt(nonce, plain, aad))
+
+
+def open_recovery(bundle_b64: str, *, rkey: bytes, user_id: str, node_pk: str) -> dict:
+ try:
+ raw = unb64(bundle_b64)
+ if raw[:4] != BUNDLE_MAGIC:
+ raise ValueError
+ aad = f"meshbay:bundle:v3|{user_id}|{node_pk}".encode()
+ return json.loads(AESGCM(rkey).decrypt(raw[5:17], raw[17:], aad))
+ except Exception as e:
+ raise BundleError("the node's recovery copy of your keys does not open with "
+ "this recovery key") from e
+
+
@dataclass
class Identity:
"""One account's keys on one node."""
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/passphrase.py b/packages/meshbay-cli/src/meshbay_cli/client/passphrase.py
new file mode 100644
index 0000000..071c708
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/client/passphrase.py
@@ -0,0 +1,64 @@
+"""
+A new passphrase's floor, and the hub's proof of work — both checked here.
+
+The hub never sees a passphrase (docs/MESHBAY_DESIGN.md §3.1), so the floor —
+12 characters and about 60 estimated bits — can only be enforced by the client:
+`auth-page.js` `passwordBits`, the same estimate, so a passphrase the page
+refuses is refused here too.
+"""
+
+import hashlib
+import math
+import re
+
+MIN_LENGTH = 12
+MIN_BITS = 60
+_WEAK = re.compile(r"(password|motdepasse|azerty|qwerty|123456|meshbay)", re.I)
+
+
+def passphrase_bits(pw: str) -> int:
+ if not pw:
+ return 0
+ pool = 0
+ if re.search(r"[a-z]", pw):
+ pool += 26
+ if re.search(r"[A-Z]", pw):
+ pool += 26
+ if re.search(r"[0-9]", pw):
+ pool += 10
+ if re.search(r"[^A-Za-z0-9]", pw):
+ pool += 32
+ # JavaScript counts UTF-16 units; a character outside the BMP is two.
+ length = len(pw.encode("utf-16-le")) // 2
+ bits = length * math.log2(pool or 1)
+ if len(set(pw)) < length / 2:
+ bits *= 0.6
+ if re.fullmatch(r"[0-9]+", pw):
+ bits *= 0.5
+ if _WEAK.search(pw):
+ bits *= 0.3
+ # Math.round: halves go up.
+ return math.floor(bits + 0.5)
+
+
+def passphrase_problem(pw: str) -> str | None:
+ """Why a new passphrase is refused, or None."""
+ if len(pw.encode("utf-16-le")) // 2 < MIN_LENGTH:
+ return f"A passphrase has {MIN_LENGTH} characters at least."
+ if passphrase_bits(pw) < MIN_BITS:
+ return ("This passphrase is too easy to guess: make it longer, or mix in "
+ "other kinds of characters.")
+ return None
+
+
+def solve_captcha(challenge: dict) -> str | None:
+ """
+ The answer to `GET /v1/users/captcha`: the number below `max_number` whose
+ `<prefix><number>` hashes to `challenge` (`captcha.js` `solve`), as the token
+ the register and reset-request bodies carry. None if there is none.
+ """
+ prefix, want = challenge["prefix"], bytes.fromhex(challenge["challenge"])
+ for n in range(int(challenge["max_number"])):
+ if hashlib.sha256(f"{prefix}{n}".encode()).digest() == want:
+ return f"{prefix}{n}.{challenge['signature']}"
+ return None
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/remove.py b/packages/meshbay-cli/src/meshbay_cli/client/remove.py
index 370cf95..ed9c65a 100644
--- a/packages/meshbay-cli/src/meshbay_cli/client/remove.py
+++ b/packages/meshbay-cli/src/meshbay_cli/client/remove.py
@@ -1,15 +1,17 @@
"""
-Deleting a file from a group (docs/MESHBAY_DESIGN.md §5.4).
+Deleting a file, or an empty folder, from a group (docs/MESHBAY_DESIGN.md §5.4).
The node decides, and on two grounds only: a key paired with it as operator, or
a live device of the account that uploaded the file — any of them, so a file
sent from a phone is deleted from the laptop. Being the group's owner on the hub
-is neither. The request is answered with a challenge the client signs with its
-key on the node; the node rebuilds what was signed itself.
+is neither. A folder has no uploader: only the operator removes one, and only
+an empty one — the node never deletes recursively. The request is answered with
+a challenge the client signs with its key on the node; the node rebuilds what
+was signed itself.
"""
from meshbay_common import MNP_VERSION
-from meshbay_common.adminop import OP_FILE_DELETE, admin_transcript
+from meshbay_common.adminop import OP_DIR_DELETE, OP_FILE_DELETE, admin_transcript
from ..errors import NODE, CliError
from .keys import b64, unb64
@@ -24,22 +26,32 @@ def may_delete(session: GroupSession, entry: dict, user_id: str) -> bool:
async def delete_file(session: GroupSession, file_id: str) -> None:
"""Delete one file. Raises `NodeRefusal` with the node's reason."""
- reply = await session.ch.request({"type": "file_delete", "v": MNP_VERSION,
- "file_id": file_id},
- "admin_challenge", "file_delete_ack")
- if reply["type"] == "file_delete_ack":
+ await _signed(session, {"type": "file_delete", "v": MNP_VERSION, "file_id": file_id},
+ OP_FILE_DELETE, file_id, "file_delete_ack")
+
+
+async def delete_dir(session: GroupSession, path: str) -> None:
+ """Remove one empty folder (root/folders), as the node's operator."""
+ await _signed(session, {"type": "dir_delete", "v": MNP_VERSION, "dir": path},
+ OP_DIR_DELETE, path, "dir_delete_ack")
+
+
+async def _signed(session: GroupSession, msg: dict, op: str, subject: str,
+ ack: str) -> None:
+ reply = await session.ch.request(msg, "admin_challenge", ack)
+ if reply["type"] == ack:
return
# What is signed is checked against what was asked, never taken from the
- # node: a challenge for another operation, file, group or node is refused.
- if (reply.get("op") != OP_FILE_DELETE or reply.get("subject") != file_id
+ # node: a challenge for another operation, subject, group or node is refused.
+ if (reply.get("op") != op or reply.get("subject") != subject
or reply.get("group_id") != session.group_id
or reply.get("node_pk") != session.node_pk):
raise CliError(NODE, "The node asked to sign something other than this deletion; "
"nothing was signed.")
- transcript = admin_transcript(op=OP_FILE_DELETE, node_pk_b64=session.node_pk,
- group_id=session.group_id, subject=file_id,
+ transcript = admin_transcript(op=op, node_pk_b64=session.node_pk,
+ group_id=session.group_id, subject=subject,
nonce=unb64(reply["nonce"]), ts=int(reply["ts"]))
await session.ch.request({"type": "admin_response", "v": MNP_VERSION,
- "op_id": reply["op_id"], "op": OP_FILE_DELETE,
+ "op_id": reply["op_id"], "op": op,
"signature": b64(session.identity.sk_ed.sign(transcript))},
- "file_delete_ack")
+ ack)
diff --git a/packages/meshbay-cli/src/meshbay_cli/client/rewrap.py b/packages/meshbay-cli/src/meshbay_cli/client/rewrap.py
new file mode 100644
index 0000000..3ad74bb
--- /dev/null
+++ b/packages/meshbay-cli/src/meshbay_cli/client/rewrap.py
@@ -0,0 +1,120 @@
+"""
+Carrying the account's keys on every node across a new passphrase
+(docs/MESHBAY_DESIGN.md §3.6).
+
+A node keeps, for browsers, a copy of the account's keys there sealed under
+`M` — which the passphrase derives — and perhaps a second copy under the
+recovery key. A new passphrase means a new `M`: each copy is opened (under the
+old `M`, else the recovery copy under the recovery key) and sealed again, the
+same keys, under the new one. Never this machine's keys in place of the
+browsers': a copy is carried, not replaced — except after a reset, for an
+account that leaves its keys for browsers from here, where the old copy no
+longer opens and this machine's is the one it chose to leave.
+
+Once per node, whatever number of groups it serves: the copy is per account
+and per node. A node that holds no keys of this account has nothing to carry;
+a node out of reach keeps its copy as it is, and this machine carries it at
+its next connection there (`session._settle_bundle`) when the old `M` is kept.
+"""
+
+import logging
+from collections.abc import Callable
+from dataclasses import dataclass, field
+
+from meshbay_common import MNP_VERSION
+
+from ..errors import NODE, CliError
+from .account import Account
+from .keys import BundleError, seal_bundle, seal_recovery
+from .paths import handle
+from .session import NodeKeyChanged, NothingHere, Rewrap, connect, open_held
+
+log = logging.getLogger(__name__)
+
+
+@dataclass
+class Carried:
+ done: list[str] = field(default_factory=list) # node keys sealed again
+ nothing: list[str] = field(default_factory=list) # groups with nothing to carry
+ unreachable: list[str] = field(default_factory=list) # groups: no node online
+ failed: list[str] = field(default_factory=list) # "group: why"
+
+ @property
+ def stragglers(self) -> list[str]:
+ return self.unreachable + self.failed
+
+
+async def carry(account: Account, groups: list[dict], *, master: bytes, pepper_version: int,
+ recovery: bytes | None = None, own_keys: bool = False,
+ only: set[str] | None = None,
+ progress: Callable[[str], None] | None = None) -> Carried:
+ """
+ Seal every reachable node's copy under `master`. `account` opens them: its
+ `M` is the one they are sealed under now. `recovery`: the recovery key, which
+ opens the recovery copy and gets a fresh one. `own_keys`: where no copy opens,
+ leave this machine's keys instead. `only`: these node keys and no others.
+ """
+ out = Carried()
+ seen: set[str] = set()
+ for group in groups:
+ name = handle(group)
+ try:
+ nodes = await account.call(account.hub.group_nodes, group["id"])
+ except CliError as e:
+ out.failed.append(f"{name}: {e.message}")
+ continue
+ nodes = [n for n in nodes if only is None or n["pk_node"] in only]
+ fresh = [n for n in nodes if n["pk_node"] not in seen]
+ if not nodes:
+ if only is None:
+ out.unreachable.append(name)
+ continue
+ if not fresh:
+ continue
+ for node in fresh:
+ if progress:
+ progress(name)
+ try:
+ what = await _carry_one(account, group, node, master, pepper_version,
+ recovery, own_keys)
+ except NodeKeyChanged as e:
+ out.failed.append(f"{name}: {e.message.splitlines()[0]}")
+ continue
+ except CliError as e:
+ out.failed.append(f"{name}: {e.message}")
+ continue
+ seen.add(node["pk_node"])
+ if what == "done":
+ out.done.append(node["pk_node"])
+ else:
+ out.nothing.append(name)
+ return out
+
+
+async def _carry_one(account: Account, group: dict, node: dict, master: bytes,
+ pepper_version: int, recovery: bytes | None, own_keys: bool) -> str:
+ try:
+ session = await connect(account, group["id"], node, rewrap=Rewrap(recovery))
+ except NothingHere:
+ return "nothing"
+ try:
+ held, node_pk = session.held, session.node_pk
+ if not held:
+ return "nothing" # this machine keeps its keys; none on the node
+ try:
+ keys = open_held(account, held, node_pk, recovery)
+ except BundleError as e:
+ if not own_keys:
+ raise CliError(NODE, f"{e}; left as it is") from e
+ keys = session.identity.to_pkcs8()
+ msg = {"type": "keypair_bundle_store", "v": MNP_VERSION,
+ "bundle_enc": seal_bundle(keys, master=master, pepper_version=pepper_version,
+ user_id=account.user_id, node_pk=node_pk)}
+ if recovery is not None:
+ msg["bundle_enc_recovery"] = seal_recovery(keys, rkey=recovery,
+ user_id=account.user_id,
+ node_pk=node_pk)
+ await session.ch.request(msg, "ack")
+ return "done"
+ finally:
+ await session.close()
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 "