summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/adminop.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-common/src/meshbay_common/adminop.py')
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py75
1 files changed, 75 insertions, 0 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
new file mode 100644
index 0000000..2d90102
--- /dev/null
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -0,0 +1,75 @@
+"""
+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)