diff options
Diffstat (limited to 'packages/meshbay-common')
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/handshake.py | 18 | ||||
| -rw-r--r-- | packages/meshbay-common/tests/test_handshake.py | 26 |
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" |