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
|
"""
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"
# Turning uploading by ordinary members on or off. Signed like the rest: the
# setting decides who may write to the operator's disk, so a node that took it
# from an unsigned message would let any member re-enable it for everyone.
OP_MEMBER_UPLOAD = "member_upload"
# 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"
# Whether the node calls TMDB at all, and whether it uses the operator's own
# API token instead of the shipped default. Signed like the rest: it turns
# on outbound third-party network traffic that did not exist before the
# Videos app (docs/mediacenter.md §5.5, §8) — an unsigned toggle would let
# any member turn on egress the operator never agreed to.
OP_TMDB_CONFIG = "tmdb_config"
# Which folder (possibly a subfolder of a shared root) is the Videos app's
# entry point for this group — per-group, unlike tmdb_config. Signed like
# the rest: it decides what every member's Videos tab shows.
OP_VIDEO_ROOT = "video_root"
OP_ROOT_ADD = "root_add"
OP_ROOT_REMOVE = "root_remove"
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/invite-pairing-v1.md). 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)
|