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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
|
"""
Admin operation challenge transcripts (MNP).
Destructive and privileged node operations are authorized by an Ed25519 signature
from the node operator, not by a JWT — the hub controls JWT issuance, so a JWT can
never establish node-level authority (see draft-v4 §4.2.x).
Finding H5: the node used to challenge the client with 32 raw random bytes and the
client signed them blind. That is an unbound signing oracle — the signed message
named no operation, no subject, no node and no time, so a signature obtained for one
purpose was structurally valid for any other, and a malicious node could ask a user
to sign bytes meaningful in a different protocol.
The transcript below fixes that:
- a fixed domain-separation prefix, so these signatures can never collide with
node_auth, revocation tokens, chunk signatures or anything added later;
- the operation and its subject, so the client can display and verify what it is
authorizing before signing;
- the node's public key, so a signature for node A is not valid on node B;
- the group, so authority does not leak across groups on a multi-group node;
- a node-chosen nonce, so signatures cannot be replayed;
- a timestamp, so stale challenges can be rejected.
Every field is length-prefixed. Plain concatenation would let a crafted subject
impersonate a following field (finding L4 applies the same rule to the GEK proof).
Both sides MUST build the transcript with this function — the client from the
fields it received, the node from the state it stored. They are compared by
producing the same bytes, never by trusting a value off the wire.
"""
ADMIN_TRANSCRIPT_PREFIX = b"meshbay:admin:v1"
# Operations that require node-operator authority.
OP_FILE_DELETE = "file_delete"
OP_DIR_DELETE = "dir_delete"
OP_INVITE_CREATE = "invite_create"
OP_MEMBER_REVOKE = "member_revoke"
# Rotating the group key is what actually takes it away from a revoked member:
# revocation stops the node serving the *next* key, and they still hold the
# current one. The node generates the new key itself with its own CSPRNG, so
# nothing arriving over MNP contributes key material — the C5b rule is about
# key material from outside, not about the instruction.
OP_GEK_ROTATE = "gek_rotate"
# Forgetting a pinned identity, so someone can pair again after losing a device.
OP_MEMBER_UNPIN = "member_unpin"
# Which group "applications" (Chat, Files, and whatever registers later) are
# shown to members. Signed like the rest: it decides what a member sees, not
# anything about key material, but an unsigned toggle would let any member
# turn a disabled one back on.
OP_APPS_ENABLED = "apps_enabled"
# How often the indexer's reconciliation backstop runs, and how long it waits
# after a file's last write before hashing it. Signed like the rest for
# consistency with every other operator-only setting here, even though the
# worst a wrong value costs is index staleness or extra disk churn — not a
# security property in itself, but the pattern (every operator setting is
# signed) is what keeps the authorization model simple to reason about.
OP_SET_SCAN_SETTINGS = "set_scan_settings"
# How many transfers one member may run at once in this group. Signed like the
# rest: an unsigned cap is one any member can raise for themselves, which makes
# the control a suggestion. The subject is "d=2,u=2" so what the operator is
# shown before signing names the outcome and not the operation.
OP_TRANSFER_LIMITS = "transfer_limits"
# Whether the node uses the operator's own API token/language instead of the
# shipped default — node-wide (docs/MESHBAY_DESIGN.md §9.7), one credential
# shared by every group. Signed like the rest: it turns on outbound
# third-party network traffic that did not exist before the Videos app
# (§8) — an unsigned change would let any member alter egress the operator
# never agreed to.
OP_TMDB_CONFIG = "tmdb_config"
# Whether the node calls TMDB *at all* for this group — per-group, unlike
# tmdb_config above: a real media-library group and a test/demo group on the
# same node need not share the decision to spend TMDB quota and make
# outbound requests. Signed for the same reason as tmdb_config.
OP_TMDB_ENABLED = "tmdb_enabled"
# Correcting a wrong automatic TMDB match — signed for the same reason as
# tmdb_config: it changes what every member sees for a show/movie, node-wide
# (media_cache is shared, not per-viewer), so an unsigned override would let
# any member vandalize another show's metadata.
OP_TMDB_OVERRIDE = "tmdb_override"
# "Re-match this one file" — drop its cached match (and any override marker)
# so the next media_meta_req re-resolves with the current matcher. Signed for
# the same reason as tmdb_override: media_cache is shared node-wide, so an
# unsigned reset would let any member wipe another's correction.
OP_TMDB_REMATCH = "tmdb_rematch"
# MusicBrainz contact is now the owner's hub email (musicbrainz.py) — no
# signed config op needed. Only the per-group toggle remains.
# Whether the node calls MusicBrainz *at all* for this group — per-group from
# the start (unlike TMDB, which started node-wide and was split later once
# the lesson was already learned once). Signed for the same reason as
# tmdb_enabled.
OP_MUSICBRAINZ_ENABLED = "musicbrainz_enabled"
OP_ROOT_ADD = "root_add"
OP_ROOT_REMOVE = "root_remove"
# One op for every application's directories. The subject is
# "<app>:<comma-joined sorted paths>" so what the operator is shown before
# signing names both the app and the outcome — "video_root" alone said neither.
OP_APP_DIRECTORIES = "app_directories"
OP_CHAT_DIRECTORY = "chat_directory"
OP_CHAT_LINK_PREVIEW = "chat_link_preview"
# Whether this group's files appear in its members' cross-group Search. A
# presentation choice and nothing more — every member still lists the group by
# opening it — but it changes what every member's Search shows, so it is the
# operator's, signed like the other per-group switches.
OP_SEARCH_LISTED = "search_listed"
# Open a new chat epoch for a group, by hand. The removals that matter open one
# by themselves (member revoke/unpin, device revoke, gek_rotate); this is the
# operator saying "do it anyway", which is the same shape as `gek_rotate` and
# signed for the same reason.
#
# There is no op for *enabling* chat encryption. It is not a setting — MNP 2.0
# has no plaintext chat to fall back to.
OP_CHAT_EPOCH = "chat_epoch"
OP_ROOT_UPDATE = "root_update"
OP_ROOT_EJECT = "root_eject"
OP_ROOT_PLUG = "root_plug"
OP_GROUP_ATTACH = "group_attach"
OP_GROUP_DETACH = "group_detach"
# OP_GEK_BUNDLE_STORE is gone. Members no longer hand the node key material at
# all: the node holds the GEK and wraps it itself, for a key the recipient proved
# they hold (see `join.py` and docs/MESHBAY_DESIGN.md §3.4). The operation existed
# only to make member-supplied bundles safe, and deleting the message is a
# stronger guarantee than authorizing it.
# A challenge older than this is refused, so a signature captured from a stale
# exchange cannot be replayed later.
ADMIN_CHALLENGE_TTL = 120 # seconds
def admin_transcript(
op: str,
node_pk_b64: str,
group_id: str,
subject: str,
nonce: bytes,
ts: int,
) -> bytes:
"""
Build the exact byte string signed for an admin operation.
`subject` identifies what is being acted on: a file_id for OP_FILE_DELETE, the
path relative to the shared root for OP_DIR_DELETE, the invitee's user_id for
OP_INVITE_CREATE.
"""
fields = [
op.encode(),
node_pk_b64.encode(),
group_id.encode(),
subject.encode(),
nonce,
str(ts).encode(),
]
out = bytearray(ADMIN_TRANSCRIPT_PREFIX)
for field in fields:
out += len(field).to_bytes(4, "big")
out += field
return bytes(out)
|