1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
|
"""The MNP token: a member's credential to a node, useless at the hub API.
A member hands whatever token it presents to every node it connects to (the MNP
handshake). That must not be the hub session token, which opens the hub API —
otherwise a node operator holds a live credential for the member. `POST
/v1/nodes/mnp-token` mints a short-lived, node-audience token for that purpose;
these tests pin that it authorises to a node and is refused by the hub API.
"""
import pytest
from meshbay_common.handshake import HandshakeError, authorize_token
async def _session_token(client, username="mnp_user_test"):
await client.post("/v1/users/register", json={
"username": username, "email": f"{username}@test.local", "auth_key": "k" * 44})
r = await client.post("/v1/users/login", json={
"username": username, "auth_key": "k" * 44})
return r.json()["access_token"]
async def _own_group(client, tok, name="g"):
return (await client.post("/v1/groups", headers={"Authorization": f"Bearer {tok}"},
json={"name": name, "visibility": "private",
"join_policy": "invite"})).json()["group_id"]
@pytest.mark.asyncio
async def test_mnp_token_endpoint_needs_a_session(client):
# No Authorization header at all — FastAPI rejects the required header (422),
# like every other authenticated route; the point is it is not minted anonymously.
r = await client.post("/v1/nodes/mnp-token")
assert r.status_code in (401, 403, 422)
@pytest.mark.asyncio
async def test_mnp_token_is_minted_for_a_member(client):
tok = await _session_token(client)
gid = await _own_group(client, tok)
r = await client.post("/v1/nodes/mnp-token", json={"group_id": gid},
headers={"Authorization": f"Bearer {tok}"})
assert r.status_code == 200
assert r.json().get("mnp_token")
@pytest.mark.asyncio
async def test_mnp_token_is_refused_at_the_hub_api(client):
"""The whole point: the credential a node receives opens nothing at the hub."""
tok = await _session_token(client, "mnp_api_test")
gid = await _own_group(client, tok)
mnp = (await client.post("/v1/nodes/mnp-token", json={"group_id": gid},
headers={"Authorization": f"Bearer {tok}"})).json()["mnp_token"]
# Presenting it to a hub endpoint fails.
r = await client.get("/v1/users/me", headers={"Authorization": f"Bearer {mnp}"})
assert r.status_code == 401
@pytest.mark.asyncio
async def test_a_session_token_is_refused_by_a_node_but_the_mnp_token_is_not(client):
"""The mirror image, at the node's decode: the session token (what the API
accepts) is refused by `authorize_token`, and the MNP token is accepted."""
from meshbay_hub.auth import hub_public_key_pem
# Make the member a member of a group so the MNP token carries it.
tok = await _session_token(client, "mnp_node_test")
H = {"Authorization": f"Bearer {tok}"}
gid = (await client.post("/v1/groups", headers=H, json={
"name": "g", "visibility": "private", "join_policy": "invite"})).json()["group_id"]
mnp = (await client.post("/v1/nodes/mnp-token", headers=H,
json={"group_id": gid})).json()["mnp_token"]
pk = hub_public_key_pem()
# The session token is refused by the node handshake (wrong audience).
with pytest.raises(HandshakeError):
authorize_token(tok, pk, group_id=gid)
# The MNP token authorises the member to the node.
peer = authorize_token(mnp, pk, group_id=gid)
assert peer.group_id == gid
@pytest.mark.asyncio
async def test_the_mnp_token_is_bound_to_the_node_it_names(client):
"""E10: a token minted for node A is refused by node B, so an operator who
captures a member's token cannot replay it to another of the member's nodes."""
from meshbay_hub.auth import hub_public_key_pem
tok = await _session_token(client, "mnp_bind_test")
H = {"Authorization": f"Bearer {tok}"}
gid = (await client.post("/v1/groups", headers=H, json={
"name": "g", "visibility": "private", "join_policy": "invite"})).json()["group_id"]
# A token bound to node A's key.
mnp = (await client.post("/v1/nodes/mnp-token", headers=H,
json={"node_pk": "node-A-pk", "group_id": gid})).json()["mnp_token"]
pk = hub_public_key_pem()
# Node B refuses it; node A accepts it.
with pytest.raises(HandshakeError, match="this node"):
authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-B-pk")
peer = authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-A-pk")
assert peer.group_id == gid
@pytest.mark.asyncio
async def test_the_mnp_token_names_only_the_group_asked_for(client):
"""It is handed to that group's node operator, who has no business learning
every other group the member belongs to."""
import jwt as _jwt
tok = await _session_token(client, "mnp_scope_test")
H = {"Authorization": f"Bearer {tok}"}
one = await _own_group(client, tok, "one")
await _own_group(client, tok, "two")
await _own_group(client, tok, "three")
mnp = (await client.post("/v1/nodes/mnp-token", headers=H,
json={"group_id": one})).json()["mnp_token"]
claims = _jwt.decode(mnp, options={"verify_signature": False})
assert claims["groups"] == [one]
@pytest.mark.asyncio
async def test_no_group_is_named_for_a_non_member(client):
from meshbay_hub.auth import hub_public_key_pem
owner = await _session_token(client, "mnp_owner_test")
gid = await _own_group(client, owner)
stranger = await _session_token(client, "mnp_stranger")
mnp = (await client.post("/v1/nodes/mnp-token", json={"group_id": gid},
headers={"Authorization": f"Bearer {stranger}"})).json()["mnp_token"]
with pytest.raises(HandshakeError) as refused:
authorize_token(mnp, hub_public_key_pem(), group_id=gid)
assert refused.value.code == "not_a_member"
@pytest.mark.asyncio
async def test_a_token_must_name_its_group(client):
tok = await _session_token(client, "mnp_nogroup_test")
r = await client.post("/v1/nodes/mnp-token", json={},
headers={"Authorization": f"Bearer {tok}"})
assert r.status_code == 422
|