aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/client/rewrap.py
blob: 3ad74bbda23ba81d5c1f97c755cae502682d3705 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
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()