aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-common')
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py18
-rw-r--r--packages/meshbay-common/tests/test_handshake.py26
2 files changed, 42 insertions, 2 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py
index 73a2858..223f064 100644
--- a/packages/meshbay-common/src/meshbay_common/handshake.py
+++ b/packages/meshbay-common/src/meshbay_common/handshake.py
@@ -53,7 +53,17 @@ NONCE_LEN = 32
class HandshakeError(Exception):
- """Refusal, with a message safe to hand to the peer."""
+ """
+ Refusal, with a message safe to hand to the peer.
+
+ `code` is the same refusal in a form a client can act on. The text is for a
+ human and may be reworded; matching on it from the client would be a string
+ comparison that breaks silently the day someone improves the wording.
+ """
+
+ def __init__(self, message: str, code: str = ""):
+ super().__init__(message)
+ self.code = code
class DenylistLike(Protocol):
@@ -170,7 +180,11 @@ def authorize_token(
raise HandshakeError("Token revoked")
if group_id not in decoded.get("groups", []):
- raise HandshakeError("Not a member of this group")
+ # Almost always a token issued before the person was added to the group:
+ # `groups` is baked in at login and the hub does not push updates. The
+ # client refreshes and retries on this code rather than telling someone
+ # who *is* a member that they are not one.
+ raise HandshakeError("Not a member of this group", code="not_a_member")
if hosted_groups is not None and group_id not in hosted_groups:
raise HandshakeError("Group not hosted on this node")
diff --git a/packages/meshbay-common/tests/test_handshake.py b/packages/meshbay-common/tests/test_handshake.py
index ba8788a..8981db8 100644
--- a/packages/meshbay-common/tests/test_handshake.py
+++ b/packages/meshbay-common/tests/test_handshake.py
@@ -197,3 +197,29 @@ def test_transcript_is_unambiguous():
def test_bindings_differ_by_transport():
"""A WebRTC proof must not be replayable on a QUIC connection."""
assert webrtc_binding(b"\xaa" * 32, b"\xbb" * 32) != quic_binding(b"cert-der")
+
+
+def test_membership_refusal_carries_a_code_a_client_can_act_on():
+ """
+ `groups` is baked into the token at login, so someone added to a group after
+ signing in is refused although they are a member. The client refreshes and
+ retries on this code — it must not have to match on the human wording, which
+ is exactly the kind of coupling that breaks when someone improves a message.
+ """
+ import jwt as _jwt
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+ from cryptography.hazmat.primitives import serialization
+
+ sk = Ed25519PrivateKey.generate()
+ pem_priv = sk.private_bytes(
+ serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption())
+ pem_pub = sk.public_key().public_bytes(
+ serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
+
+ token = _jwt.encode({"sub": "u1", "jti": "j1", "scope": "user", "groups": []},
+ pem_priv, algorithm="EdDSA")
+
+ with pytest.raises(HandshakeError) as excinfo:
+ authorize_token(token, pem_pub, group_id="g" * 32)
+ assert excinfo.value.code == "not_a_member"