""" 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_INVITE_CREATE = "invite_create" # 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 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)