Skip to content

Commit ae06090

Browse files
committed
grants(), revoke() and introduce(), for parity with the Go SDK
Grants carry aud, the extension that binds an introduction's grant to the peer meant to honor it, and a Peer does not honor a grant bound to another key. The peer's advertised address (hello.addr) is remembered, so an introduction can pass it on.
1 parent 02806dc commit ae06090

4 files changed

Lines changed: 102 additions & 3 deletions

File tree

‎src/awp/peer.py‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from .wire import (ACKED_TYPES, BACKOFF_CAP, BACKOFF_INITIAL, CHUNK_SIZE, DEFAULT_BLOB_LIMIT,
2828
DEFAULT_GRANT_TTL, DEFAULT_PING_INTERVAL, FSYNC, MAX_LINE, MY_CAPS,
2929
PROTOCOL_VERSION, REQUEST_MIMES, CommandError, StartupError, UlidGen,
30-
b64std, b64url, blob_refs, dumps_line, format_key, key_fp, key_matches,
30+
b64std, b64url, blob_refs, dumps_line, format_key, grant_hash, key_fp, key_matches,
3131
mint_grant, now_ts, parse_key, safe_name, verify_grant)
3232

3333

@@ -759,6 +759,9 @@ def grant_honored(self, g: dict[str, Any], ps: PeerState) -> bool:
759759
ok, _ = verify_grant(g)
760760
if not ok:
761761
return False
762+
aud = g.get("aud")
763+
if isinstance(aud, str) and not key_matches(aud, self.identity.pub):
764+
return False # meant for another peer to honor
762765
iss = parse_key(g["iss"])
763766
if self.is_root(iss):
764767
return True
@@ -920,6 +923,51 @@ def caps(self, to: str) -> set[str]:
920923
"""The capabilities the peer holds on this Peer, from honored grants."""
921924
return self.honored_caps(self._ps(to))
922925

926+
def grants(self) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
927+
"""The grants this Peer issued and the ones it holds, valid now,
928+
each with a "hash" naming it for revoke()."""
929+
with_hash = lambda gs: [dict(g, hash=grant_hash(g)) for g in gs]
930+
return with_hash(self.issued.valid()), with_hash(self.held.valid())
931+
932+
def revoke(self, hash: str) -> bool:
933+
"""Stop honoring a grant this Peer issued, named by its hash. The
934+
peer's copy stays valid elsewhere until it expires."""
935+
for g in self.issued.items:
936+
if grant_hash(g) == hash:
937+
self.issued.remove(g.get("sig"))
938+
for ps in self.peers.values():
939+
ps.grants.remove(g.get("sig"))
940+
return True
941+
return False
942+
943+
def introduce(self, to: str, peer: str, caps: Iterable[str] = (), ttl: float = DEFAULT_GRANT_TTL,
944+
thread: str | None = None) -> Sent:
945+
"""Hand ``to`` the key and address of ``peer``, with a grant for
946+
``caps`` that ``peer`` honors if it trusts this Peer with introduce.
947+
The grant is bound to ``peer`` (aud), so it confers nothing on anyone
948+
else. The address is the one ``peer`` announced or was dialed at."""
949+
if to == peer:
950+
raise CommandError("cannot introduce a peer to itself")
951+
target = self._ps(to)
952+
known = self.peers.get(key_fp(parse_key(peer)))
953+
if known is None or known.key_raw is None:
954+
raise CommandError(f"unknown peer {peer}")
955+
address = known.meta.get("addr") or known.meta.get("dialed")
956+
if not address:
957+
raise CommandError(f"no known address for {peer}")
958+
g = mint_grant(self.identity, to, list(caps), ttl, aud=peer)
959+
self.issued.add(g)
960+
intro = {"key": known.key_str or format_key(known.key_raw), "address": address}
961+
if known.meta.get("name"):
962+
intro["name"] = known.meta["name"]
963+
obj = self.envelope("introduce", th=thread, peer=intro, grant=g)
964+
target.sendonce.add(obj)
965+
conn = self.connections.get(target.fp)
966+
if conn is not None:
967+
conn.queue_once(obj["id"])
968+
self._new_work(target)
969+
return Sent(id=obj["id"], thread=thread or "", to=target.key_str or to)
970+
923971
async def bye(self, to: str, reason: str = "done") -> None:
924972
"""Close the connection to the peer gracefully and park it: no
925973
reconnection until something new is queued for it."""

‎src/awp/store.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,14 @@ def valid(self) -> list:
423423
self.prune()
424424
return list(self.items)
425425

426+
def remove(self, sig) -> bool:
427+
keep = [g for g in self.items if g.get("sig") != sig]
428+
if len(keep) == len(self.items):
429+
return False
430+
self.items = keep
431+
self.save()
432+
return True
433+
426434
def save(self) -> None:
427435
atomic_write_json(self.path, self.items)
428436

@@ -461,7 +469,7 @@ def save_meta(self, key_str: str, hello) -> None:
461469
self.key_str = key_str
462470
self.meta.update({"key": format_key(self.key_raw), "key_as_sent": key_str})
463471
if isinstance(hello, dict):
464-
for k in ("name", "about", "caps"):
472+
for k in ("name", "about", "caps", "addr"):
465473
if k in hello:
466474
self.meta[k] = hello[k]
467475
self.meta["last_connected"] = now_ts()

‎src/awp/wire.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -317,13 +317,24 @@ def verify_grant(g):
317317
return True, "ok"
318318

319319

320-
def mint_grant(identity: "Identity", sub: str, caps, ttl: float) -> dict:
320+
def mint_grant(identity: "Identity", sub: str, caps, ttl: float, aud: str | None = None) -> dict:
321+
"""A grant from identity to sub for caps, valid ttl seconds. aud binds
322+
it to the one peer meant to honor it (an introduction's grant)."""
321323
g = {"iss": identity.key, "sub": sub, "caps": list(caps),
322324
"exp": fmt_ts(math.ceil(time.time() + ttl), millis=False),
323325
"nonce": b64url(os.urandom(32))}
326+
if aud:
327+
g["aud"] = aud
324328
g["sig"] = b64url(identity.sign(canonical_json(g)))
325329
return g
326330

327331

332+
def grant_hash(g: dict) -> str:
333+
"""How a grant is named: the SHA-256 of its canonical form, signature
334+
included, shortened; the same as the reference implementation's."""
335+
import hashlib
336+
return hashlib.sha256(canonical_json(g)).hexdigest()[:32]
337+
338+
328339
# ---------------------------------------------------------------- persistence
329340

‎tests/test_peer.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,35 @@ async def test_conformance_peer_dialing(tmp):
184184
failed = [r for r in rep["results"] if r["status"] == "fail"]
185185
assert not failed, json.dumps(failed, indent=1)
186186
assert rep["passed"] >= 10
187+
188+
189+
async def test_revoke_and_introduce(tmp):
190+
"""a revokes a grant it issued; a introduces c to b with a grant bound to c."""
191+
async with awp.Peer(os.path.join(tmp, "a"), name="a@test", ping_interval=2) as a, \
192+
awp.Peer(os.path.join(tmp, "b"), name="b@test", ping_interval=2) as b, \
193+
awp.Peer(os.path.join(tmp, "c"), name="c@test", ping_interval=2, trust=[]) as c:
194+
b_addr = await b.listen("unix:" + os.path.join(tmp, "b.sock"))
195+
c_addr = await c.listen("unix:" + os.path.join(tmp, "c.sock"))
196+
await a.connect(b_addr, timeout=10)
197+
await a.connect(c_addr, timeout=10)
198+
await next_of(b, awp.Connected)
199+
await next_of(c, awp.Connected)
200+
201+
g = a.grant(b.key, ["fs:read"], 3600)
202+
await next_of(b, awp.GrantReceived)
203+
issued, _ = a.grants()
204+
assert [x["sig"] for x in issued] == [g["sig"]] and "hash" in issued[0]
205+
assert a.caps(b.key) == {"fs:read"}
206+
assert a.revoke(issued[0]["hash"]) and not a.revoke(issued[0]["hash"])
207+
assert a.caps(b.key) == set() and a.grants()[0] == []
208+
209+
sent = a.introduce(b.key, c.key, ["fs:read"], 3600)
210+
assert sent.to == b.key
211+
intro = await next_of(b, awp.Introduced)
212+
assert (intro.peer, intro.key, intro.address) == (a.key, c.key, c_addr)
213+
assert intro.grant and intro.grant["aud"] == c.key and intro.grant["sub"] == b.key
214+
# The grant is bound to c: a itself does not honor it for b.
215+
assert a.caps(b.key) == set()
216+
# b meets c through the address it was handed.
217+
assert await b.connect(intro.address, timeout=10) == c.key
218+
await next_of(c, awp.Connected)

0 commit comments

Comments
 (0)