""" 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" # 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 -- the same rule # member_upload's on/off subject follows. OP_TRANSFER_LIMITS = "transfer_limits" # 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" # "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" # 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" # One op for every application's directories. The subject is # ":" 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" # 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/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)