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()
|