aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py18
-rw-r--r--packages/meshbay-common/tests/test_handshake.py26
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js46
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js7
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py3
5 files changed, 87 insertions, 13 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"
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 1ef878a..6fefe0f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -765,7 +765,7 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk
return results;
}
-function GroupPage({ groupId, group, token, username, userId }) {
+function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) {
const [status, setStatus] = useState('idle');
const [entries, setEntries] = useState([]);
const [cached, setCached] = useState(false);
@@ -786,6 +786,9 @@ function GroupPage({ groupId, group, token, username, userId }) {
const [retryKey, setRetryKey] = useState(0);
const transportRef = useRef(null);
const gekRef = useRef(null);
+ // One refresh per mount: if a fresh token still says we are not a member, we
+ // really are not, and retrying forever would hide that.
+ const refreshedRef = useRef(false);
const submitJoinCode = useCallback((e) => {
e.preventDefault();
@@ -902,14 +905,24 @@ function GroupPage({ groupId, group, token, username, userId }) {
cacheGroupIndex(groupId, group ? group.name : groupId, freshEntries);
} catch (err) {
- if (!cancelled) {
- // The node has never seen this browser for this account: it needs a
- // one-time code from the operator before it will hand over the group
- // key. Not an error to shout about — a step in joining.
- if (err.reason === 'code_required') setNeedsCode(true);
- setError(err.message);
- setStatus('error');
+ if (cancelled) return;
+
+ // Our token predates being added to this group. Refresh once and retry
+ // rather than telling someone who was just invited that they are not a
+ // member — which is what the node honestly sees, and is useless to them.
+ if (err.reason === 'not_a_member' && !refreshedRef.current && onRefreshAuth) {
+ refreshedRef.current = true;
+ try {
+ if (await onRefreshAuth()) return; // new token → effect re-runs
+ } catch { /* fall through to the message below */ }
}
+
+ // The node has never seen this browser for this account: it needs a
+ // one-time code from the operator before it will hand over the group
+ // key. Not an error to shout about — a step in joining.
+ if (err.reason === 'code_required') setNeedsCode(true);
+ setError(err.message);
+ setStatus('error');
}
};
@@ -2653,6 +2666,20 @@ function App() {
},
};
+ // Group membership is baked into the access token at login and the hub does not
+ // push updates, so someone invited after they signed in carries a token that
+ // says they are in nothing. Refreshing re-reads membership from the database.
+ const refreshAuth = useCallback(async () => {
+ if (!user || !user.refreshToken) return null;
+ const data = await hubFetch('/v1/users/token/refresh', {
+ method: 'POST', body: { refresh_token: user.refreshToken },
+ });
+ const u = { ...user, token: data.access_token };
+ setUser(u);
+ saveAuth(u);
+ return data.access_token;
+ }, [user]);
+
let page;
if (route === '/login' || route === '/register') {
page = route === '/register'
@@ -2677,7 +2704,8 @@ function App() {
const group = groups.find(g => g.id === groupId);
page = html`<${GroupPage}
groupId=${groupId} group=${group} token=${user.token}
- username=${user.username} userId=${user.userId} />`;
+ username=${user.username} userId=${user.userId}
+ onRefreshAuth=${refreshAuth} />`;
} else if (route === '/admin') {
page = (user.role === 'moderator' || user.role === 'admin')
? html`<${AdminPage} token=${user.token} />`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 271d11f..c200674 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -314,8 +314,13 @@ class MeshBayTransport {
// A node that answers a handshake with anything other than a challenge is not
// running the mutual protocol. Accepting a bare handshake_ack here would let a
// peer skip proving GEK possession entirely (C3/C6).
- throw new Error(
+ const rejected = new Error(
'MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`));
+ // `not_a_member` usually means our token predates being added to the group;
+ // the caller refreshes it and tries again rather than showing that to someone
+ // who was invited thirty seconds ago.
+ rejected.reason = reply.code || '';
+ throw rejected;
}
/**
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 416e84c..46dda64 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -356,7 +356,8 @@ class WebRTCPeerSession:
except HandshakeError as refusal:
# HandshakeError messages are authored to be peer-safe, unlike arbitrary
# exception text (L3) — the client needs to know *why* it was refused.
- self._send({"type": "error", "detail": str(refusal)})
+ self._send({"type": "error", "detail": str(refusal),
+ "code": getattr(refusal, "code", "")})
self._audit_auth_failed(group_id, str(refusal))
return