summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/protocol.py
blob: 900acc29f6d332b7468593b58a2eee4f824c5f77 (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
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
"""
MeshBay protocol constants and message type definitions.

MNP (Mesh Node Protocol) — v0.1
MHP (Mesh Bay Hub Protocol) — v0.1

All wire messages are length-prefixed msgpack (4-byte big-endian length header).
Every message carries a "v" field for protocol version.
"""

from dataclasses import dataclass, field
from typing import Any

# The wire versions live in meshbay_common/__init__.py — one source, because a
# second copy here said "0.1" while every message on the wire carried "0.2".
# Nothing imported it, which is the only reason it was harmless.
from meshbay_common import MNP_VERSION, MHP_VERSION   # noqa: F401  (re-export)


# ── MNP message types ─────────────────────────────────────────────────────────

class MNP:
    HANDSHAKE       = "handshake"
    HANDSHAKE_ACK   = "handshake_ack"
    INDEX_SYNC      = "index_sync"       # full Mesh Group Index
    INDEX_DELTA     = "index_delta"      # incremental update
    # Node -> already-connected members: "the operator's node is scanning
    # right now, N/M bytes done". Never the entries themselves (that is
    # INDEX_SYNC/INDEX_DELTA's job) — just enough for a presence dot to
    # animate. Pushed periodically while scanning, and once more on the
    # transition back to idle, so the indicator is guaranteed to turn off.
    INDEX_PROGRESS  = "index_progress"
    FILE_REQUEST    = "file_req"         # request chunk(s)
    FILE_CHUNK      = "file_chunk"       # encrypted chunk response
    STREAM_SEGMENT  = "stream_seg"       # HLS/DASH segment
    CHAT_MESSAGE    = "chat_msg"         # Double Ratchet message
    CHAT_ATTACHMENT = "chat_attach"      # attachment metadata
    CHAT_HISTORY    = "chat_hist"       # request message history (newest, or before a cursor)
    CHAT_HISTORY_RESPONSE = "chat_hist_resp"  # history response with messages
    # Liveness on an *already open* channel. A peer that goes away without
    # closing leaves a DataChannel that still reads as connected until the next
    # real request hangs, and there was no way to ask. This is not a discovery
    # mechanism: opening a connection in order to ping costs a full ICE/DTLS
    # handshake (measured at 0.6-7 s across two ISPs), so presence in the group
    # list comes from the hub's socket registry instead.
    PING            = "ping"
    PONG            = "pong"
    # GEK_REQUEST / GEK_RESPONSE were removed (NS3, and finding L1): the node must
    # never serve the GEK in plaintext. Members obtain it by unwrapping their own
    # ECIES bundle. The constants lingered after the handlers were deleted, leaving
    # the wire contract looking as though the endpoint still existed.
    FILE_UPLOAD      = "file_upload"       # client pushes file chunk to node
    FILE_UPLOAD_ACK  = "file_upload_ack"   # node acknowledges chunk receipt
    DIR_CREATE       = "dir_create"        # client → node: make a directory
    DIR_CREATE_ACK   = "dir_create_ack"    # node → client: created
    DIR_DELETE       = "dir_delete"        # client → node: remove an empty directory
    DIR_DELETE_ACK   = "dir_delete_ack"    # node → client: removed
    FILE_DELETE      = "file_delete"       # client requests file deletion
    FILE_DELETE_ACK  = "file_delete_ack"   # node confirms deletion
    STREAM_REQUEST   = "stream_req"        # client requests MSE video stream
    STREAM_INIT      = "stream_init"       # node sends codec info + signals stream start
    STREAM_DATA      = "stream_data"       # node sends encrypted fMP4 segment
    STREAM_END       = "stream_end"        # node signals end of stream
    STREAM_MORE      = "stream_more"       # client → node: room for N more segments
    STREAM_STOP      = "stream_stop"       # client → node: nobody is watching any more
    EPHEMERAL_STREAM = "ephemeral_stream"  # reserved — mobile live push
    HANDSHAKE_CHALLENGE  = "handshake_challenge"   # node → client: GEK proof nonce
    HANDSHAKE_RESPONSE   = "handshake_response"    # client → node: HMAC(GEK, nonce)
    ADMIN_CHALLENGE      = "admin_challenge"       # node → client: Ed25519 sign challenge
    ADMIN_RESPONSE       = "admin_response"        # client → node: Ed25519 signature
    # GEK_BUNDLE_STORE was removed with the invite redesign: the node wraps the GEK
    # itself, for a key the recipient proved possession of, so no member ever hands
    # the node key material (C5b, and the H3 substitution it enabled).
    GEK_BUNDLE_FETCH     = "gek_bundle_fetch"      # client → node: request own wrapped GEK
    GEK_BUNDLE_RESP      = "gek_bundle_resp"       # node → client: wrapped GEK bundle
    KEYPAIR_BUNDLE_STORE = "keypair_bundle_store"  # client → node: store encrypted keypair bundle
    KEYPAIR_BUNDLE_FETCH = "keypair_bundle_fetch"  # client → node: request own keypair bundle
    KEYPAIR_BUNDLE_RESP  = "keypair_bundle_resp"   # node → client: encrypted keypair bundle
    KEYPAIR_BUNDLE_DELETE = "keypair_bundle_delete"  # client → node: withdraw own backup
    JOIN_REQUEST         = "join_request"          # client → node: pair/recognise this identity
    JOIN_RESULT          = "join_result"           # node → client: outcome + wrapped GEK
    INVITE_CREATE        = "invite_create"         # operator → node: issue a pairing code
    MEMBER_REVOKE        = "member_revoke"         # operator → node: stop serving the key
    MEMBER_REVOKE_ACK    = "member_revoke_ack"
    MEMBER_UNPIN         = "member_unpin"          # operator → node: forget an identity
    MEMBER_UNPIN_ACK     = "member_unpin_ack"
    MEMBER_UPLOAD        = "member_upload"         # operator → node: may members upload?
    MEMBER_UPLOAD_ACK    = "member_upload_ack"
    APPS_ENABLED         = "apps_enabled"          # operator → node: which group apps to show
    APPS_ENABLED_ACK     = "apps_enabled_ack"
    SET_SCAN_SETTINGS     = "set_scan_settings"     # operator → node: reconcile/debounce timing
    SET_SCAN_SETTINGS_ACK = "set_scan_settings_ack"
    MEDIA_META_REQ        = "media_meta_req"        # client → node: TMDB metadata for a path
    MEDIA_META_RESP       = "media_meta_resp"       # node → client: TMDB metadata (or none)
    VIDEO_ROOT            = "video_root"             # operator → node: which folder is the Videos entry point
    VIDEO_ROOT_ACK        = "video_root_ack"
    TMDB_CONFIG           = "tmdb_config"            # operator → node: enable/disable TMDB, set token
    TMDB_CONFIG_ACK       = "tmdb_config_ack"        # node → everyone: new TMDB config (never the token)
    SEASON_META_REQ       = "season_meta_req"        # client → node: TMDB overview/poster for one season
    SEASON_META_RESP      = "season_meta_resp"       # node → client: season-level TMDB fields (or none)
    TMDB_SEARCH_REQ       = "tmdb_search_req"        # client → node: candidate TMDB matches for a query
    TMDB_SEARCH_RESP      = "tmdb_search_resp"       # node → client: candidate list (id, title, year, poster)
    TMDB_OVERRIDE         = "tmdb_override"          # operator → node: replace a show/movie's TMDB match
    TMDB_OVERRIDE_ACK     = "tmdb_override_ack"
    # Device linking. A new device files a request bound to a code it displays;
    # an already-pinned device of the same account approves it. Neither the hub
    # nor the node can produce the countersignature.
    DEVICE_REQUEST       = "device_add_request"    # new device → node
    DEVICE_REQUEST_ACK   = "device_add_request_ack"
    DEVICE_LOOKUP        = "device_lookup"         # approver → node: find by code
    DEVICE_LOOKUP_RESULT = "device_lookup_result"
    DEVICE_ADD           = "device_add"            # approver → node: countersigned
    DEVICE_ADD_ACK       = "device_add_ack"
    DEVICE_LIST          = "device_list"           # anyone → node: my devices
    DEVICE_LIST_RESULT   = "device_list_result"
    DEVICE_REVOKE        = "device_revoke"         # a device retires another
    # Rotation is the half of revocation that revocation cannot do: the node
    # generates a fresh key itself, so no key material crosses the wire.
    GEK_ROTATE           = "gek_rotate"            # operator → node: new group key
    GEK_ROTATE_ACK       = "gek_rotate_ack"
    INVITE_RESULT        = "invite_result"         # node → operator: the code, once
    NODE_STATUS          = "node_status"           # operator → node: list all groups + roots
    NODE_STATUS_ACK      = "node_status_ack"       # node → operator: full status
    ROOT_ADD             = "root_add"              # operator → node: add a directory to a group
    ROOT_ADD_ACK         = "root_add_ack"          # node → operator: confirmed
    ROOT_REMOVE          = "root_remove"           # operator → node: remove a root from a group
    ROOT_REMOVE_ACK      = "root_remove_ack"       # node → operator: confirmed
    ROSTER_READ          = "roster_read"            # operator → node: list pinned identities + members
    ROSTER_READ_ACK      = "roster_read_ack"
    DENYLIST_READ        = "denylist_read"          # operator → node: show denylist entries
    DENYLIST_READ_ACK    = "denylist_read_ack"
    DENYLIST_CLEAR       = "denylist_clear"         # operator → node: remove denylist entry(ies)
    DENYLIST_CLEAR_ACK   = "denylist_clear_ack"
    GROUP_ATTACH         = "group_attach"           # operator → node: host a new group
    GROUP_ATTACH_ACK     = "group_attach_ack"
    GROUP_DETACH         = "group_detach"           # operator → node: stop hosting a group
    GROUP_DETACH_ACK     = "group_detach_ack"
    NODE_RELOAD          = "node_reload"            # operator → node: re-read node.toml
    NODE_RELOAD_ACK      = "node_reload_ack"


# ── Index entry ───────────────────────────────────────────────────────────────

@dataclass
class IndexEntry:
    id:        str    # blake3 hash of file (hex)
    name:      str    # filename
    path:      str    # path relative to shared directory
    size:      int    # bytes
    type:      str    # video | audio | image | document | archive | other
    added_at:  int    # unix timestamp
    duration:  int | None = None   # seconds, for media
    thumb_hash: str | None = None  # blake3 of thumbnail
    uploader_id: str | None = None  # user_id of who uploaded (None = pre-existing on disk)
    uploader_pk: str | None = None  # Ed25519 public key of uploader (base64 raw 32 bytes)
    width:      int | None = None  # pixels, video only
    height:     int | None = None  # pixels, video only
    display_title: str | None = None  # parsed or cleaned-filename title, Videos app
    season:     int | None = None  # parsed season number, Videos app
    episode:    int | None = None  # parsed episode number, Videos app


def index_entry_wire(e: IndexEntry) -> dict:
    """
    The wire-dict shape used by INDEX_SYNC/INDEX_DELTA hand-built messages
    (as opposed to GroupIndex.serialize()'s asdict() encoding of the whole
    index). Centralized so the three call sites that build these
    (webrtc_server._do_index_sync, daemon._broadcast_index_change's two
    branches) can't drift from each other as fields are added.
    """
    return {
        "id": e.id, "name": e.name, "path": e.path,
        "size": e.size, "type": e.type, "added_at": e.added_at,
        "uploader_id": e.uploader_id,
        "duration": e.duration, "thumb_hash": e.thumb_hash,
        "width": e.width, "height": e.height,
        "display_title": e.display_title,
        "season": e.season, "episode": e.episode,
    }


@dataclass
class IndexDelta:
    base_version: int
    version:      int
    additions:    list[IndexEntry] = field(default_factory=list)
    deletions:    list[str] = field(default_factory=list)  # list of ids
    # Same id as before (same file, same content hash), different field
    # values — e.g. the Videos app's async enrichment filling in duration/
    # thumb_hash/etc. after the file was already indexed with hash+size only.
    # A distinct list from `additions`: `GroupIndex.diff()` only ever adds an
    # id here once it has already appeared unchanged in a prior snapshot.
    updates:      list[IndexEntry] = field(default_factory=list)


# ── Chunk request/response ────────────────────────────────────────────────────

@dataclass
class ChunkRequest:
    file_id:     str   # blake3 hash of file (hex)
    chunk_index: int

@dataclass
class ChunkResponse:
    chunk_index:    int
    plaintext_size: int
    nonce_b64:      str
    ct_b64:         str
    ct_hash_b64:    str
    pt_hash_b64:    str
    sig_b64:        str
    pk_node_b64:    str
    file_hash_b64:  str