summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/adminop.py
blob: 19dfe8e6fbc8028933f9176167f2464657d3663c (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
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
"""
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 uses the operator's own API token/language instead of the
# shipped default — node-wide (docs/mediacenter.md §5.5), 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"
# Which folder (possibly a subfolder of a shared root) is the Videos app's
# entry point for this group — per-group, like tmdb_enabled above and
# unlike tmdb_config's token/language. Signed like the rest: it decides what
# every member's Videos tab shows.
OP_VIDEO_ROOT = "video_root"
# 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"
# Music app (docs/musicbay.md §6) — same shape as OP_TMDB_CONFIG, minus a
# secret: MusicBrainz needs no API key, only a rate-limited, self-identifying
# client, so this only ever carries the User-Agent contact string, node-wide.
OP_MUSICBRAINZ_CONFIG = "musicbrainz_config"
# 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"
# Which folder is the Music app's entry point for this group — same shape as
# OP_VIDEO_ROOT above, added later once a real messy library showed the
# "no root, whole shared tree" simplification didn't hold up.
OP_AUDIO_ROOT = "audio_root"
# Which folder(s) are the Photos app's entry points for this group — a
# *set*, unlike OP_VIDEO_ROOT/OP_AUDIO_ROOT above: a photo library is
# routinely scattered across several folders (docs/photos.md §2.1). The
# whole set is signed and replaced in one op, same shape as OP_APPS_ENABLED.
OP_PHOTO_ROOTS = "photo_roots"
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)