Identity, step three (hubs): signed requests, grants, members as identities
Claude · 2026-10-27 · f7e3adca545a5339c17ab20e43e247c52ee19cca · parents da7cb19
identity.py signs and verifies requests: Ed25519 over the HMAC message with a domain string and the audience (the receiving hub's id) in front, with the device note in X-Pyrtan-Device when the key is a device.
grants.py keeps which roots may do what on a hub (ask, chat, run, operate), capped by a device's note: --allow WHO=ABILITIES (a name is resolved and pinned once), GET/POST /allow and /disallow for the operator, the newest identity document per grant from POST /identity (anyone; it is signed), the name's domain or its routes, refreshed hourly and when a device has not been seen in a day.
The hub checks a key-signed request against its audience (named at /api). Behind a token it may do what its root is granted and nothing else; on an open hub a key only says who is asking. A member that follows with a key whose root holds run is trusted, may use the key as its actor id, and is dropped when its note, its root's document or its grant says it may no longer be. The hub serves the documents it holds at /.well-known/pyrtan/id/. CORS allows the signature headers.
CLI: --sign on the commands that call a hub (signs as your root key), pyrtan hub --allow/--grants, pyrtan allow, allowed, disallow, id push.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jgoLgxA3qCSMVWdoDv
Changes
src/pyrtan/cli.py | 188 ++++++++++++++++++++++++++++++++-------- src/pyrtan/federation.py | 62 ++++++++----- src/pyrtan/grants.py | 251 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/pyrtan/hub.py | 121 +++++++++++++++++++++++--- src/pyrtan/identity.py | 78 +++++++++++++++++ src/pyrtan/service.py | 24 +++-- tests/test_grants.py | 317 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 968 insertions(+), 73 deletions(-)
diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index 33a284e..44638b4 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py@@ -28,6 +28,7 @@pyrtan console --out FILE write the console as one static HTML file pyrtan id new|show|check|device|revoke|route|name your identity: a key, any domain pyrtan id resolve NAME@DOMAIN what identity a name is, pinned the first time+ pyrtan allow WHO --may ask,run --at HUB grant an identity abilities on your hub; allowed, disallow``serve`` and ``hub`` take ``--token`` (or ``$PYRTAN_TOKEN``) to require a bearer token, ``--cors ORIGIN`` to let a browser elsewhere call them, and@@ -86,7 +87,7 @@ def main(argv: list[str] | None = None) -> int:p.add_argument("--hub", help="forward trees not held here to this hub or service") p.add_argument("--env", action="append", default=[], metavar="K=V", help="a value trees may read as {{ env.K }} (repeatable)") p.add_argument("--egress", choices=EGRESS_POLICIES, default="any", help="'public' refuses private, loopback and link-local addresses")- _token_arg(p, "the token the hub (--hub) requires, if any")+ _token_arg(p, "the token the hub (--hub) requires, if any", sign=False)p = sub.add_parser("match", help="how a sentence reads against the trees: the rewrites, every trigger's verdict, the winner") p.add_argument("text")@@ -132,7 +133,7 @@ def main(argv: list[str] | None = None) -> int:p.add_argument("--egress", choices=EGRESS_POLICIES, default=os.environ.get("PYRTAN_EGRESS", "any")) p.add_argument("--hub", help="delegate to this hub what the trees here do not hold") p.add_argument("--json", action="store_true", help="one JSON outcome per line instead of prose")- _token_arg(p, "the hub's token, if any")+ _token_arg(p, "the hub's token, if any", sign=False)p = sub.add_parser("watches", help="what a running service or hub is watching, and what each watch last did") p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="a running service or hub (default $PYRTAN_AT or the local hub)")@@ -174,6 +175,8 @@ def main(argv: list[str] | None = None) -> int:p.add_argument("--store", metavar="DIR", default=os.environ.get("PYRTAN_STORE"), help="where this hub keeps blobs, its own and others' (default $PYRTAN_STORE, else next to --state)") p.add_argument("--replicas", type=int, default=int(os.environ.get("PYRTAN_REPLICAS", "2")), help="how many members or hubs should keep each of this hub's blobs (default $PYRTAN_REPLICAS or 2)") p.add_argument("--chat", action="store_true", default=os.environ.get("PYRTAN_CHAT", "").lower() in ("1", "true", "yes"), help="keep a lobby at /chat where people meet a random stranger here or at a hub this one follows, or a bot made of the trees it routes to (default $PYRTAN_CHAT)")+ p.add_argument("--allow", action="append", default=[a for a in os.environ.get("PYRTAN_ALLOW", "").split() if a], metavar="WHO=ABILITIES", help="grant an identity (pyr:… or name@domain, resolved and pinned now) abilities here: ask, chat, run, operate (repeatable; default $PYRTAN_ALLOW, space-separated)")+ p.add_argument("--grants", metavar="FILE", default=os.environ.get("PYRTAN_GRANTS"), help="keep the grants in this file (default $PYRTAN_GRANTS, else grants.json beside --state, else in memory)")p.add_argument("--report-gaps", action="store_true", default=os.environ.get("PYRTAN_REPORT_GAPS", "").lower() in ("1", "true", "yes"), help="put the gaps this swarm's catalog trees meet (tree id, node, value; never inputs) into its announces, so they reach the tree's author through the federation and pyrtan.com (default $PYRTAN_REPORT_GAPS)") p.add_argument("--quiet", action="store_true")@@ -273,6 +276,22 @@ def main(argv: list[str] | None = None) -> int:p.add_argument("--into", required=True, metavar="CHECKOUT", help="a checkout of github.com/b-i-t-8/pyrtan (or any repository with a catalog/ directory)") p.add_argument("--owner", action="append", default=[], metavar="LOGIN", help="GitHub login(s) that own the namespace; needed when the namespace is new")+ p = sub.add_parser("allow", help="grant an identity abilities on your hub: ask, chat, run, operate")+ p.add_argument("who", metavar="pyr:…|NAME@DOMAIN", help="an identity, or a name your hub resolves and pins now")+ p.add_argument("--may", required=True, metavar="ABILITIES", help="comma-separated: ask (the routed paths), chat (the lobby), run (be a trusted member), operate (what the token allows)")+ p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="your hub (default $PYRTAN_AT or the local hub)")+ _token_arg(p, "your hub's token, if any")++ p = sub.add_parser("allowed", help="the identities your hub grants anything, and what")+ p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="your hub (default $PYRTAN_AT or the local hub)")+ p.add_argument("--json", action="store_true")+ _token_arg(p, "your hub's token, if any")++ p = sub.add_parser("disallow", help="take back everything your hub grants an identity")+ p.add_argument("who", metavar="pyr:…|NAME@DOMAIN")+ p.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="your hub (default $PYRTAN_AT or the local hub)")+ _token_arg(p, "your hub's token, if any")+p = sub.add_parser("console", help="write the console as one static HTML file, to host anywhere") p.add_argument("--out", required=True, help="the file to write ('-' for stdout)")@@ -308,6 +327,8 @@ def main(argv: list[str] | None = None) -> int:q.add_argument("--json", action="store_true", help="print the claim, and the identity document if the domain serves one") q = ids.add_parser("pins", help="the names you have resolved, and the identity each is pinned to") q.add_argument("--forget", metavar="NAME@DOMAIN", help="drop a pin, so the next resolve takes what the domain says")+ q = ids.add_parser("push", help="give a hub your newest identity document, so it knows your devices and revocations now")+ q.add_argument("--at", default=os.environ.get("PYRTAN_AT", "http://127.0.0.1:8700"), help="the hub (default $PYRTAN_AT or the local hub)")q = ids.add_parser("wellknown", help="build /.well-known/pyrtan/ from a directory of name claims, for any domain to serve") q.add_argument("names", type=Path, metavar="DIR", help="<name>.json claims, and <name>.id.json identity documents") q.add_argument("--domain", required=True, help="the domain that will serve them; every claim must be for it")@@ -322,16 +343,48 @@ def main(argv: list[str] | None = None) -> int:"search": cmd_search, "pull": cmd_pull, "index": cmd_index, "publish": cmd_publish, "console": cmd_console, "follow": cmd_follow, "join": cmd_join, "network": cmd_network, "unfollow": cmd_unfollow, "peers": cmd_peers, "directory": cmd_directory, "reports": cmd_reports, "keep": cmd_keep, "kept": cmd_kept, "fetch": cmd_fetch, "id": cmd_id,+ "allow": cmd_allow, "allowed": cmd_allowed, "disallow": cmd_disallow,} return commands[args.command](args)-def _token_arg(p: argparse.ArgumentParser, help_text: str) -> None:+def _token_arg(p: argparse.ArgumentParser, help_text: str, *, sign: bool = True) -> None:p.add_argument("--token", default=os.environ.get("PYRTAN_TOKEN"), help=f"{help_text} (default $PYRTAN_TOKEN)")+ if sign:+ p.add_argument("--sign", action="store_true", default=os.environ.get("PYRTAN_SIGN", "").lower() in ("1", "true", "yes"),+ help="sign requests with your identity's key (pyrtan id new), for a hub that grants it (default $PYRTAN_SIGN)")+++_AUDIENCES: dict[str, str] = {}+++def _call(args: argparse.Namespace, method: str, url: str, payload: Any = None, **kw: Any) -> tuple[int, Any]:+ """``http_json`` with the command's token, and with its key's signature under --sign."""+ headers = _key_headers(args, method, url, payload) if getattr(args, "sign", False) else None+ return http_json(method, url, payload, token=getattr(args, "token", None), headers=headers, **kw)+++def _key_headers(args: argparse.Namespace, method: str, url: str, payload: Any) -> dict[str, str]:+ """Sign as the root key in $PYRTAN_IDENTITY (or ~/.config/pyrtan/identity), for the+ audience the hub names at /api."""+ from urllib.parse import urlsplit+ from . import identity+ parts = urlsplit(url)+ base = f"{parts.scheme}://{parts.netloc}"+ if base not in _AUDIENCES:+ status, api = http_json("GET", f"{base}/api", timeout=10.0)+ audience = api.get("audience") if status == 200 and isinstance(api, dict) else None+ if not isinstance(audience, str):+ raise SystemExit(f"pyrtan: {base} names no audience to sign for; is it a hub of pyrtan 0.12 or later?")+ _AUDIENCES[base] = audience+ key = identity.Key.load(identity.home() / "root.key")+ if method == "POST" and payload is None:+ payload = {}+ return identity.sign_request(key, _AUDIENCES[base], method, parts.path or "/", payload)def _access_args(p: argparse.ArgumentParser) -> None:- _token_arg(p, "require this bearer token on every request but /health and the console")+ _token_arg(p, "require this bearer token on every request but /health and the console", sign=False)p.add_argument("--cors", action="append", default=[], metavar="ORIGIN", help="let a browser at this origin call (repeatable; '*' for any; e.g. https://pyrtan.com)")@@ -492,12 +545,12 @@ def _answer_questions(outcome: Outcome, resume) -> Outcome:def cmd_ask(args: argparse.Namespace) -> int: at = args.at.rstrip("/") try:- status, body = http_json("POST", f"{at}/intent", {"text": args.text, "trace": not args.no_trace}, token=args.token)+ status, body = _call(args, "POST", f"{at}/intent", {"text": args.text, "trace": not args.no_trace})except Exception as e: print(f"pyrtan: {at}: {e}", file=sys.stderr) return 3 if status == 401:- print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)+ print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)return 3 if status != 200 or not isinstance(body, dict): print(f"pyrtan: {at} answered {status}: {body}", file=sys.stderr)@@ -505,7 +558,7 @@ def cmd_ask(args: argparse.Namespace) -> int:outcome = outcome_from_dict(body) def remote_resume(snapshot: dict[str, Any], answer: Any) -> Outcome:- st, bd = http_json("POST", f"{at}/resume", {"snapshot": snapshot, "answer": answer, "trace": not args.no_trace}, token=args.token)+ st, bd = _call(args, "POST", f"{at}/resume", {"snapshot": snapshot, "answer": answer, "trace": not args.no_trace})return outcome_from_dict(bd) if isinstance(bd, dict) else Outcome("error", "", reason="remote", say=f"{at} answered {st}") outcome = _answer_questions(outcome, remote_resume)@@ -517,7 +570,7 @@ def cmd_chat(args: argparse.Namespace) -> int:want = "bot" if args.bot else "people" if args.people else "anyone" def post(path: str, body: dict[str, Any], timeout: float = 15.0) -> tuple[int, Any]:- return http_json("POST", f"{at}{path}", body, timeout=timeout, token=args.token)+ return _call(args, "POST", f"{at}{path}", body, timeout=timeout)try: status, body = post("/chat/start", {"want": want, "interests": args.interests})@@ -525,7 +578,7 @@ def cmd_chat(args: argparse.Namespace) -> int:print(f"pyrtan: {at}: {e}", file=sys.stderr) return 3 if status == 401:- print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)+ print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)return 3 if status != 200 or not isinstance(body, dict) or "seat" not in body: print(f"pyrtan: {at}: {body.get('say') if isinstance(body, dict) else body or status}", file=sys.stderr)@@ -682,12 +735,12 @@ def cmd_watch(args: argparse.Namespace) -> int:def cmd_watches(args: argparse.Namespace) -> int: try:- status, body = http_json("GET", f"{args.at.rstrip('/')}/watches", token=args.token)+ status, body = _call(args, "GET", f"{args.at.rstrip('/')}/watches")except Exception as e: print(f"pyrtan: {args.at}: {e}", file=sys.stderr) return 3 if status == 401:- print(f"pyrtan: {args.at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)+ print(f"pyrtan: {args.at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)return 3 watches = (body or {}).get("watches", []) if isinstance(body, dict) else [] if args.json:@@ -718,7 +771,7 @@ def cmd_grow(args: argparse.Namespace) -> int:gaps: list[dict[str, Any]] = [] if args.at: try:- status, body = http_json("GET", f"{args.at.rstrip('/')}/gaps", token=args.token)+ status, body = _call(args, "GET", f"{args.at.rstrip('/')}/gaps")except Exception as e: print(f"pyrtan: {args.at}: {e}", file=sys.stderr) return 3@@ -816,16 +869,22 @@ def _from_report(r: dict[str, Any]) -> dict[str, Any]:def cmd_hub(args: argparse.Namespace) -> int: from .hub import serve_hub+ from .identity import IdentityErrorfollow = [url for url in args.follow if url != "seed"] join = list(args.join) + (["default"] if "seed" in args.follow and "default" not in args.join else [])- hub, server = serve_hub(- args.host, args.port, name=args.name, peers=args.peer, poll_every=args.poll, quiet=args.quiet, token=args.token, cors=args.cors,- federate=args.federate or bool(args.advertise) or bool(follow) or bool(join) or args.listed, advertise=args.advertise, federation_secret=args.federation_secret,- state=args.state, open_follows=not args.closed, follow=follow, join=join, listed=args.listed,- trees=[Path(t) for t in args.trees], handout=[Path(t) for t in args.handout], env=_kv(args.env), gaps=args.gaps, egress=args.egress,- store=args.store, replicas=args.replicas, report_gaps=args.report_gaps, unanswered=args.unanswered, watch=args.watch, chat=args.chat,- )+ try:+ hub, server = serve_hub(+ args.host, args.port, name=args.name, peers=args.peer, poll_every=args.poll, quiet=args.quiet, token=args.token, cors=args.cors,+ federate=args.federate or bool(args.advertise) or bool(follow) or bool(join) or args.listed, advertise=args.advertise, federation_secret=args.federation_secret,+ state=args.state, open_follows=not args.closed, follow=follow, join=join, listed=args.listed,+ trees=[Path(t) for t in args.trees], handout=[Path(t) for t in args.handout], env=_kv(args.env), gaps=args.gaps, egress=args.egress,+ store=args.store, replicas=args.replicas, report_gaps=args.report_gaps, unanswered=args.unanswered, watch=args.watch, chat=args.chat,+ allow=args.allow, grants=args.grants,+ )+ except IdentityError as e:+ print(f"pyrtan hub: {e}", file=sys.stderr)+ return 1fed = f", federated as {hub.federation.id}" + (f" following {len(hub.federation.following)}" if hub.federation.following else "") + (", listed" if hub.federation.listed else "") if hub.federation else "" local = f", holding {len(hub.local.trees)} tree(s)" if hub.local else "" if hub.local is not None and hub.local.service.watcher is not None:@@ -834,6 +893,8 @@ def cmd_hub(args: argparse.Namespace) -> int:local += f", handing out {len(hub.handout_registry.trees())} tree(s) to trusted members" if hub.lobby is not None: local += ", chatting at /chat"+ if len(hub.grants):+ local += f", granting {len(hub.grants)} identit{'y' if len(hub.grants) == 1 else 'ies'}"print(f"pyrtan hub at {server.url}: {len(hub.peers)} peer(s){local}{fed}" + _access_note(args), flush=True) try: server.wait()@@ -850,12 +911,12 @@ def cmd_gaps(args: argparse.Namespace) -> int:print("pyrtan gaps: give --at URL of a running service or hub", file=sys.stderr) return 2 try:- status, body = http_json("GET", f"{args.at.rstrip('/')}/gaps", token=args.token)+ status, body = _call(args, "GET", f"{args.at.rstrip('/')}/gaps")except Exception as e: print(f"pyrtan: {args.at}: {e}", file=sys.stderr) return 3 if status == 401:- print(f"pyrtan: {args.at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)+ print(f"pyrtan: {args.at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)return 3 gaps = (body or {}).get("gaps", []) if isinstance(body, dict) else [] if args.json:@@ -937,12 +998,12 @@ def cmd_network(args: argparse.Namespace) -> int:return _hub_post(args, "/leave", {"network": args.name}, lambda b: f"{args.at} left {args.name}" if b.get("left") else f"{args.at} was not in {args.name}") at = args.at.rstrip("/") try:- status, body = http_json("GET", f"{at}/networks", token=args.token)+ status, body = _call(args, "GET", f"{at}/networks")except Exception as e: print(f"pyrtan: {at}: {e}", file=sys.stderr) return 3 if status == 401:- print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)+ print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)return 3 if status != 200 or not isinstance(body, dict): print(f"pyrtan: {at} is not a federated hub ({status})", file=sys.stderr)@@ -970,12 +1031,12 @@ def cmd_keep(args: argparse.Namespace) -> int:def cmd_kept(args: argparse.Namespace) -> int: at = args.at.rstrip("/") try:- status, body = http_json("GET", f"{at}/keep", token=args.token)+ status, body = _call(args, "GET", f"{at}/keep")except Exception as e: print(f"pyrtan: {at}: {e}", file=sys.stderr) return 3 if status == 401:- print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)+ print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)return 3 if status != 200 or not isinstance(body, dict): print(f"pyrtan: {at} is not a federated hub ({status})", file=sys.stderr)@@ -994,12 +1055,12 @@ def cmd_kept(args: argparse.Namespace) -> int:def cmd_fetch(args: argparse.Namespace) -> int: at = args.at.rstrip("/") try:- status, body = http_json("POST", f"{at}/fetch", {"key": args.key}, token=args.token)+ status, body = _call(args, "POST", f"{at}/fetch", {"key": args.key})except Exception as e: print(f"pyrtan: {at}: {e}", file=sys.stderr) return 3 if status == 401:- print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)+ print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)return 3 if status != 200 or not isinstance(body, dict): print(f"pyrtan: {at} answered {status}", file=sys.stderr)@@ -1076,12 +1137,13 @@ def cmd_unfollow(args: argparse.Namespace) -> int:def _hub_post(args: argparse.Namespace, path: str, payload: dict[str, Any], said) -> int: at = args.at.rstrip("/") try:- status, body = http_json("POST", f"{at}{path}", payload, token=args.token)+ status, body = _call(args, "POST", f"{at}{path}", payload)except Exception as e: print(f"pyrtan: {at}: {e}", file=sys.stderr) return 3 if status == 401:- print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)+ said_401 = body.get("say") if isinstance(body, dict) and getattr(args, "sign", False) else None+ print(f"pyrtan: {at}: {said_401}" if said_401 else f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)return 3 if status == 404: print(f"pyrtan: {at} is not a federated hub; start it with --federate", file=sys.stderr)@@ -1096,12 +1158,12 @@ def _hub_post(args: argparse.Namespace, path: str, payload: dict[str, Any], saiddef cmd_peers(args: argparse.Namespace) -> int: at = args.at.rstrip("/") try:- status, body = http_json("GET", f"{at}/peers", token=args.token)+ status, body = _call(args, "GET", f"{at}/peers")except Exception as e: print(f"pyrtan: {at}: {e}", file=sys.stderr) return 3 if status == 401:- print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)+ print(f"pyrtan: {at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)return 3 if status != 200 or not isinstance(body, dict): print(f"pyrtan: {at} answered {status}", file=sys.stderr)@@ -1242,12 +1304,12 @@ def cmd_unanswered(args: argparse.Namespace) -> int:print("pyrtan unanswered: give --at URL of a running service or hub", file=sys.stderr) return 2 try:- status, body = http_json("GET", f"{args.at.rstrip('/')}/unanswered", token=args.token)+ status, body = _call(args, "GET", f"{args.at.rstrip('/')}/unanswered")except Exception as e: print(f"pyrtan: {args.at}: {e}", file=sys.stderr) return 3 if status == 401:- print(f"pyrtan: {args.at} needs a token: pass --token or set $PYRTAN_TOKEN", file=sys.stderr)+ print(f"pyrtan: {args.at} needs a token: pass --token or set $PYRTAN_TOKEN, or --sign as an identity it grants", file=sys.stderr)return 3 entries = (body or {}).get("unanswered", []) if isinstance(body, dict) else [] if args.json:@@ -1305,7 +1367,7 @@ def cmd_id(args: argparse.Namespace) -> int:return { "new": _id_new, "show": _id_show, "check": _id_check, "device": _id_device, "revoke": _id_revoke, "route": _id_route, "name": _id_name,- "resolve": _id_resolve, "pins": _id_pins, "wellknown": _id_wellknown,+ "resolve": _id_resolve, "pins": _id_pins, "wellknown": _id_wellknown, "push": _id_push,}[args.id_command](args, directory) except IdentityError as e: print(f"pyrtan id: {e}", file=sys.stderr)@@ -1511,5 +1573,63 @@ def _id_wellknown(args: argparse.Namespace, directory: Path) -> int:return 1 if problems else 0+def _id_push(args: argparse.Namespace, directory: Path) -> int:+ _, doc = _id_load(directory)+ at = args.at.rstrip("/")+ try:+ status, body = http_json("POST", f"{at}/identity", {"document": doc}, timeout=15.0)+ except Exception as e:+ print(f"pyrtan id: {at}: {e}", file=sys.stderr)+ return 3+ if status != 200:+ print(f"pyrtan id: {at} refused the document: {(body or {}).get('say') if isinstance(body, dict) else status}", file=sys.stderr)+ return 1+ print(f"{at} holds your document of {time.strftime('%Y-%m-%d %H:%M', time.localtime(doc['updated']))}: {len(doc['devices'])} device(s), {len(doc['revoked'])} revoked")+ return 0+++# ---------------------------------------------------------------- grants+++def cmd_allow(args: argparse.Namespace) -> int:+ from .grants import parse_may+ from .identity import IdentityError+ try:+ may = parse_may(args.may)+ except IdentityError as e:+ print(f"pyrtan allow: {e}", file=sys.stderr)+ return 2+ return _hub_post(args, "/allow", {"who": args.who, "may": may}, lambda b: f"{args.at} grants {b['grant']['name'] or b['grant']['id']} ({b['grant']['id']}): {', '.join(b['grant']['may'])}")+++def cmd_disallow(args: argparse.Namespace) -> int:+ return _hub_post(args, "/disallow", {"who": args.who}, lambda b: f"{args.at} grants {args.who} nothing now" if b["removed"] else f"{args.at} granted {args.who} nothing")+++def cmd_allowed(args: argparse.Namespace) -> int:+ at = args.at.rstrip("/")+ try:+ status, body = _call(args, "GET", f"{at}/allow")+ except Exception as e:+ print(f"pyrtan: {at}: {e}", file=sys.stderr)+ return 3+ if status in (401, 403):+ print(f"pyrtan: {at}: {(body or {}).get('say') if isinstance(body, dict) else status}", file=sys.stderr)+ return 3+ if status != 200 or not isinstance(body, dict):+ print(f"pyrtan: {at} answered {status}; is it a hub of pyrtan 0.12 or later?", file=sys.stderr)+ return 3+ if args.json:+ print(json.dumps(body, indent=2, ensure_ascii=False))+ return 0+ grants = body.get("grants", [])+ print(f"{at} (audience {body.get('audience')}) grants {len(grants)} identit{'y' if len(grants) == 1 else 'ies'}")+ for g in grants:+ doc = g.get("document")+ held = f"document of {time.strftime('%Y-%m-%d', time.localtime(doc['updated']))}, {doc['devices']} device(s), {doc['revoked']} revoked" if doc else "no document yet (pyrtan id push)"+ print(f" {g['name'] or '-':28} {g['id']} may {','.join(g['may'])} {held}")+ return 0++if __name__ == "__main__": sys.exit(main()) diff --git a/src/pyrtan/federation.py b/src/pyrtan/federation.py index 5d949f2..b3a4a0f 100644 --- a/src/pyrtan/federation.py +++ b/src/pyrtan/federation.py@@ -111,6 +111,7 @@ ACTIVITY_TYPES = ("Follow", "Accept", "Reject", "Undo", "Announce", "Keep", "KepCHAT_TYPES = ("Meet", "Say", "Part") # a lobby's: two strangers on two hubs ASK_TIMEOUT = 30.0 # seconds a hub waits for a member to answer an Ask MEMBER_PREFIX = "urn:pyrtan:member:" # an actor with no inbox: a browser or a phone, which polls a mailbox+KEY_PREFIX = "pyr:" # a member may be its device key instead (_meta/identity.md §7)MAILBOX_MAX = 200 # activities a hub holds for a member that has not polled REPORT_TTL = 30 * 86400 # a gap report unheard of for this long is dropped REPORT_MAX = 2000@@ -309,8 +310,12 @@ class Remote:last_error: str | None = None since: float = field(default_factory=time.time) kind: str = "hub" # hub | member- trusted: bool = False # a member that joined with the operator's token: it may run the hub's trees+ trusted: bool = False # a member that joined with the operator's token, or as an identity granted run: it may run the hub's treesnetwork: str | None = None # the id of the network the follow was made in; None for a follow outside any+ trusted_by: str | None = None # "token" or "key": how a trusted member came to be+ identity: str | None = None # the root a member followed as, if it signed the follow with a key+ device: str | None = None # the key that signed it+ note: dict[str, Any] | None = None # its device note, when the key is a device@property def link(self) -> str:@@ -326,14 +331,17 @@ class Remote:return "down" def to_dict(self) -> dict[str, Any]:- return {"url": self.url, "name": self.name, "kind": self.kind, "state": self.state, "link": self.link, "trees": [t["name"] for t in self.trees], "last_ok": self.last_ok, "last_error": self.last_error, "trusted": self.trusted, "network": self.network}+ return {"url": self.url, "name": self.name, "kind": self.kind, "state": self.state, "link": self.link, "trees": [t["name"] for t in self.trees], "last_ok": self.last_ok, "last_error": self.last_error, "trusted": self.trusted, "network": self.network, "identity": self.identity}def to_state(self) -> dict[str, Any]:- return {"url": self.url, "inbox": self.inbox, "secret": self.secret, "name": self.name, "state": self.state, "since": self.since, "kind": self.kind, "trusted": self.trusted, "network": self.network}+ return {"url": self.url, "inbox": self.inbox, "secret": self.secret, "name": self.name, "state": self.state, "since": self.since, "kind": self.kind, "trusted": self.trusted, "network": self.network,+ "trusted_by": self.trusted_by, "identity": self.identity, "device": self.device, "note": self.note}@classmethod def from_state(cls, d: dict[str, Any]) -> "Remote":- return cls(d["url"], d["inbox"], d["secret"], d.get("name", ""), d.get("state", "pending"), since=float(d.get("since", time.time())), kind=d.get("kind", "hub"), trusted=bool(d.get("trusted", False)), network=d.get("network") if isinstance(d.get("network"), str) else None)+ text = lambda k: d.get(k) if isinstance(d.get(k), str) else None # noqa: E731+ return cls(d["url"], d["inbox"], d["secret"], d.get("name", ""), d.get("state", "pending"), since=float(d.get("since", time.time())), kind=d.get("kind", "hub"), trusted=bool(d.get("trusted", False)), network=text("network"),+ trusted_by=text("trusted_by"), identity=text("identity"), device=text("device"), note=d.get("note") if isinstance(d.get("note"), dict) else None)def _shape_report(e: dict[str, Any], reporter: str, now: float) -> dict[str, Any] | None:@@ -600,7 +608,7 @@ class Federation:with self._lock: known = {n.id for n in self.networks.values()} for e in entries:- if not isinstance(e, dict) or not isinstance(e.get("id"), str) or not e["id"].startswith(("http://", "https://", MEMBER_PREFIX)):+ if not isinstance(e, dict) or not isinstance(e.get("id"), str) or not e["id"].startswith(("http://", "https://", MEMBER_PREFIX, KEY_PREFIX)):continue hub_id = e["id"].rstrip("/") if hub_id == self.id:@@ -623,7 +631,7 @@ class Federation:self.directory_entries[key] = { "id": hub_id, "name": str(e.get("name") or "")[:80],- "kind": "member" if hub_id.startswith(MEMBER_PREFIX) else "hub",+ "kind": "member" if hub_id.startswith((MEMBER_PREFIX, KEY_PREFIX)) else "hub","trees": [str(t)[:80] for t in e.get("trees", []) if isinstance(t, (str, int))][:200], "seen": seen, "via": via,@@ -1121,9 +1129,11 @@ class Federation:return None return actor if verify(secret, method, path, body, h) is None else None- def handle_inbox(self, activity: Any, headers: dict[str, str] | None, trusted: bool = False) -> tuple[int, dict[str, Any]]:- """``trusted`` says the request also carried the operator's token: a- member that follows that way may run the hub's trees."""+ def handle_inbox(self, activity: Any, headers: dict[str, str] | None, trusted: bool = False, *, trusted_by: str | None = None, signer: Any = None) -> tuple[int, dict[str, Any]]:+ """``trusted`` says the request also carried the operator's token, or+ a key whose root this hub grants ``run`` (``trusted_by`` says which):+ a member that follows that way may run the hub's trees. ``signer`` is+ the :class:`pyrtan.identity.Signer` of a key-signed request, if any."""h = _lower(headers) if not isinstance(activity, dict) or activity.get("type") not in ACTIVITY_TYPES or not isinstance(activity.get("actor"), str): return 400, {"status": "error", "reason": "bad-activity", "say": f"Send an activity of type {', '.join(ACTIVITY_TYPES)} with an actor."}@@ -1133,7 +1143,7 @@ class Federation:kind = activity["type"] self.inbox_log.append({**_logged(activity), "received": time.time()}) if kind == "Follow":- return self._on_follow(actor, activity, h, trusted)+ return self._on_follow(actor, activity, h, trusted, trusted_by=trusted_by, signer=signer)followed = self.following.get(actor) follower = self.followers.get(actor) if kind in ("Announce", "Keep", "Kept", "Fetch", "Answer", *CHAT_TYPES):@@ -1239,16 +1249,21 @@ class Federation:return 404, {"status": "error", "reason": "not-kept", "say": f"Nothing kept here for {actor} under {key!r}."} return 200, {"status": "ok", "blob": blob} if kind == "Undo":- with self._lock:- self.followers.pop(actor, None)- self.mailboxes.pop(actor, None)- self._handed.pop(actor, None)- self.store.drop_kept(actor)- self._save()+ self.drop_follower(actor)return 200, {"status": "ok"} return 400, {"status": "error", "reason": "bad-activity"}- def _on_follow(self, actor: str, activity: dict[str, Any], h: dict[str, str], trusted: bool = False) -> tuple[int, dict[str, Any]]:+ def drop_follower(self, actor: str) -> bool:+ """Forget a follower, its mailbox, what we handed it and what we keep for it."""+ with self._lock:+ gone = self.followers.pop(actor, None) is not None+ self.mailboxes.pop(actor, None)+ self._handed.pop(actor, None)+ self.store.drop_kept(actor)+ self._save()+ return gone++ def _on_follow(self, actor: str, activity: dict[str, Any], h: dict[str, str], trusted: bool = False, *, trusted_by: str | None = None, signer: Any = None) -> tuple[int, dict[str, Any]]:pair = activity.get("secret") inbox = activity.get("inbox") if not isinstance(pair, str) or len(pair) < 16 or not isinstance(inbox, str):@@ -1275,8 +1290,11 @@ class Federation:if inbox == "mailbox": # A member: a browser or a phone, with no address of its own. It # polls its mailbox here, signed, instead of being posted to.- if not actor.startswith(MEMBER_PREFIX) or len(actor) < len(MEMBER_PREFIX) + 16:- return 400, {"status": "error", "reason": "bad-activity", "say": f"A member's id is {MEMBER_PREFIX}<at least 16 characters>."}+ if actor.startswith(KEY_PREFIX):+ if signer is None or signer.key != actor:+ return 401, {"status": "error", "reason": "unsigned", "say": f"A member whose id is a key signs its follow with that key (X-Pyrtan-Key: {actor})."}+ elif not actor.startswith(MEMBER_PREFIX) or len(actor) < len(MEMBER_PREFIX) + 16:+ return 400, {"status": "error", "reason": "bad-activity", "say": f"A member's id is {MEMBER_PREFIX}<at least 16 characters>, or its key, pyr:…."}name = str(activity.get("name") or "")[:80] kind = "member" else:@@ -1291,6 +1309,10 @@ class Federation:kind = "hub" with self._lock: remote = Remote(actor, inbox, pair, name, state="accepted", last_ok=time.time(), kind=kind, trusted=bool(trusted and kind == "member"), network=net.id if net else None)+ if remote.trusted:+ remote.trusted_by = trusted_by or "token"+ if signer is not None and kind == "member":+ remote.identity, remote.device, remote.note = signer.root, signer.key, signer.noteself.followers[actor] = remote self._handed.pop(actor, None) self._save()@@ -1298,7 +1320,7 @@ class Federation:self.deliver(remote, self._activity("Announce", self._announcement(remote))) if remote.trusted: self.hand_out()- return 202, {"status": "ok", "say": f"{self.id} accepts {actor}.", "mailbox": f"{self.id}/mailbox" if kind == "member" else None, "trusted": remote.trusted}+ return 202, {"status": "ok", "say": f"{self.id} accepts {actor}.", "mailbox": f"{self.id}/mailbox" if kind == "member" else None, "trusted": remote.trusted, "identity": remote.identity}# ------------------------------------------------------------ views diff --git a/src/pyrtan/grants.py b/src/pyrtan/grants.py new file mode 100644 index 0000000..0f6ee3b --- /dev/null +++ b/src/pyrtan/grants.py@@ -0,0 +1,251 @@+"""Grants: which identities may do what on this hub.++An identity grants nothing by itself (``_meta/identity.md`` §1): a hub+keeps its own list, from ``--allow`` and from its operator at ``/allow``,+of roots and what each may do here: ``ask`` (the routed paths behind a+token), ``chat`` (a seat in a lobby behind a token), ``run`` (follow as a+member and be handed trees) and ``operate`` (everything the token allows).+A device may do what its root's grant says, intersected with its note's+``may``.++A grant made by name (``alice@pyrtan.com``) resolves the name once and is+held against the identifier from then on: the name is the pin, and is+never resolved again, so a domain that later points it elsewhere moves+nothing here. Each grant keeps+the newest identity document it has seen, from a push to ``/identity``,+from the name's domain or from the document's own routes, re-read at+least hourly and on a request from a device not seen in a day; a device+the document revokes is refused. The list lives in a JSON file beside+the hub's state, or in memory without one.+"""++from __future__ import annotations++import json+import threading+import time+from dataclasses import dataclass, field+from pathlib import Path+from typing import Any, Callable++from . import identity, names+from .identity import ABILITIES, IdentityError, Signer++REFRESH_EVERY = 3600.0 # seconds between reads of a grant's identity document+DEVICE_UNSEEN = 86400.0 # a device not seen for this long makes its root's document be re-read++# Reached with a key whatever its grants: following as a member is open, and trust is decided there.+OPEN = frozenset({"/inbox"})+++def ability_for(method: str, path: str) -> str | None:+ """The ability a key-signed request needs for ``path`` on a hub with a token; None for+ the paths anyone may reach (the public ones, the inbox, a pushed identity document)."""+ from .service import public_path+ if public_path(path) or path in OPEN:+ return None+ if path in ("/intent", "/run", "/resume") or (method == "GET" and path == "/capabilities"):+ return "ask"+ if path == "/chat" or path.startswith("/chat/"):+ return "chat"+ return "operate"+++def parse_may(text: Any) -> list[str]:+ """``"ask,run"`` (or a list) as abilities, each once, in the canonical order."""+ items = text if isinstance(text, list) else str(text or "").replace(" ", "").split(",")+ items = [a for a in items if a]+ unknown = [a for a in items if a not in ABILITIES]+ if unknown or not items:+ raise IdentityError(f"abilities are some of {', '.join(ABILITIES)}" + (f"; not {', '.join(unknown)}" if unknown else ""))+ return [a for a in ABILITIES if a in items]+++def parse_spec(spec: str) -> tuple[str, list[str]]:+ """``WHO=ask,run`` as ``(who, abilities)``; WHO is ``pyr:…`` or ``name@domain``."""+ who, eq, may = spec.rpartition("=")+ if not eq or not who:+ raise IdentityError(f"{spec!r}: write WHO=ABILITIES, e.g. alice@pyrtan.com=ask,chat")+ identity.parse(who)+ return who, parse_may(may)+++@dataclass+class Grant:+ id: str+ may: list[str]+ name: str | None = None+ doc: dict[str, Any] | None = None+ fetched: float = 0.0+ granted: float = field(default_factory=time.time)++ def view(self) -> dict[str, Any]:+ return {"id": self.id, "name": self.name, "may": self.may, "granted": self.granted,+ "document": {"updated": self.doc["updated"], "routes": self.doc["routes"], "devices": len(self.doc["devices"]), "revoked": len(self.doc["revoked"])} if self.doc else None}++ def to_state(self) -> dict[str, Any]:+ return {"id": self.id, "may": self.may, "name": self.name, "doc": self.doc, "fetched": self.fetched, "granted": self.granted}+++class Grants:+ def __init__(self, path: str | Path | None = None, *, fetch: names.Fetch | None = None,+ on_change: Callable[[str], None] | None = None) -> None:+ self.path = Path(path) if path else None+ self.fetch = fetch+ self.on_change = on_change # called with a root whose grant or document changed+ self.grants: dict[str, Grant] = {}+ self.seen: dict[str, float] = {} # device -> when it last signed here+ self._lock = threading.RLock()+ if self.path and self.path.exists():+ self._load()++ # ------------------------------------------------------------ the list++ def __len__(self) -> int:+ return len(self.grants)++ def by_name(self, name: str) -> Grant | None:+ key = str(identity.parse(name))+ return next((g for g in self.grants.values() if g.name == key), None)++ def allow(self, who: str, may: list[str]) -> Grant:+ """Grant ``who`` (``pyr:…`` or a name, resolved and pinned now) the abilities ``may``."""+ may = parse_may(may)+ parsed = identity.parse(who)+ if parsed.form == "name":+ held = self.by_name(who)+ claim = names.resolve(who, fetch=self.fetch) if held is None else None+ ident = held.id if held else claim["id"]+ name = str(parsed)+ else:+ ident, name, claim = parsed.id, None, None+ with self._lock:+ grant = self.grants.get(ident)+ if grant is None:+ grant = self.grants[ident] = Grant(ident, may, name)+ else:+ grant.may = may+ grant.name = grant.name or name+ self._save()+ if claim is not None and grant.doc is None:+ self.refresh_one(ident)+ self._changed(ident)+ return grant++ def disallow(self, who: str) -> bool:+ parsed = identity.parse(who)+ grant = self.by_name(who) if parsed.form == "name" else self.grants.get(parsed.id)+ if grant is None:+ return False+ with self._lock:+ self.grants.pop(grant.id, None)+ self._save()+ self._changed(grant.id)+ return True++ def view(self) -> list[dict[str, Any]]:+ with self._lock:+ return [g.view() for g in sorted(self.grants.values(), key=lambda g: (g.name or "~", g.id))]++ # ------------------------------------------------------------ callers++ def may(self, signer: Signer, *, now: float | None = None) -> set[str]:+ """What ``signer`` may do here: its root's grant, capped by the device note, and+ nothing for a device its root's document revokes. Notes a device's visit."""+ now = time.time() if now is None else now+ grant = self.grants.get(signer.root)+ if grant is None:+ return set()+ if signer.note is None:+ return set(grant.may)+ if grant.doc is not None and identity.device_status(grant.doc, signer.key, now=now) == "revoked":+ return set()+ last = self.seen.get(signer.key)+ self.seen[signer.key] = now+ if last is None or now - last > DEVICE_UNSEEN:+ threading.Thread(target=self.refresh_one, args=(signer.root,), daemon=True).start()+ return set(grant.may) & set(signer.note["may"])++ def still(self, root: str, device: str, note: dict[str, Any] | None, ability: str, *, now: float | None = None) -> bool:+ """Whether a member that followed as ``device`` for ``root`` may still do ``ability``."""+ if note is not None and identity.check_device(note, root=root, now=now) is not None:+ return False+ return ability in self.may(Signer(device, root, note), now=now)++ # ------------------------------------------------------------ documents++ def document(self, ident: str) -> dict[str, Any] | None:+ grant = self.grants.get(ident.lower())+ return grant.doc if grant else None++ def take_document(self, doc: Any, *, now: float | None = None) -> str | None:+ """Keep ``doc`` if it is a granted root's and newer than ours; None, or why not."""+ if not isinstance(doc, dict) or not isinstance(doc.get("id"), str):+ return "not an identity document"+ grant = self.grants.get(doc["id"].lower())+ if grant is None:+ return "this hub grants that identity nothing, so keeps no document for it"+ problem = identity.check_identity(doc, ident=grant.id, now=now)+ if problem:+ return problem+ with self._lock:+ if grant.doc is not None and doc["updated"] <= grant.doc["updated"]:+ return None+ grant.doc = doc+ self._save()+ self._changed(grant.id)+ return None++ def refresh_one(self, ident: str) -> None:+ """Look for a newer document where the grant's name and routes say it lives."""+ grant = self.grants.get(ident)+ if grant is None:+ return+ hosts = list(grant.doc["routes"]) if grant.doc else []+ if grant.name:+ hosts.insert(0, identity.parse(grant.name).domain)+ grant.fetched = time.time()+ for host in dict.fromkeys(hosts):+ try:+ doc = names.fetch_identity(ident, host, fetch=self.fetch)+ except IdentityError:+ continue # an onion route, for now+ if doc is not None:+ self.take_document(doc)++ def refresh(self, *, now: float | None = None) -> None:+ now = time.time() if now is None else now+ for ident in [g.id for g in list(self.grants.values()) if now - g.fetched >= REFRESH_EVERY]:+ self.refresh_one(ident)++ # ------------------------------------------------------------ state++ def _changed(self, ident: str) -> None:+ if self.on_change:+ self.on_change(ident)++ def _save(self) -> None:+ if not self.path:+ return+ with self._lock:+ data = {"pyrtan-grants": 1, "grants": [g.to_state() for g in self.grants.values()]}+ self.path.parent.mkdir(parents=True, exist_ok=True)+ temp = self.path.with_suffix(self.path.suffix + ".tmp")+ temp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")+ temp.replace(self.path)++ def _load(self) -> None:+ assert self.path+ try:+ data = json.loads(self.path.read_text(encoding="utf-8"))+ except (OSError, json.JSONDecodeError) as e:+ raise IdentityError(f"{self.path}: {e}") from None+ if not isinstance(data, dict) or data.get("pyrtan-grants") != 1:+ raise IdentityError(f"{self.path} is not a pyrtan grants file")+ for d in data.get("grants", []):+ try:+ ident = identity.normal(d["id"])+ doc = d.get("doc") if identity.check_identity(d.get("doc"), ident=ident) is None else None+ self.grants[ident] = Grant(ident, parse_may(d.get("may")), d.get("name"), doc, float(d.get("fetched", 0)), float(d.get("granted", 0)))+ except (IdentityError, KeyError, TypeError, ValueError):+ continuediff --git a/src/pyrtan/hub.py b/src/pyrtan/hub.py index ad4c19d..85b8d88 100644 --- a/src/pyrtan/hub.py +++ b/src/pyrtan/hub.py@@ -34,6 +34,19 @@ answer the same requests, so a client need not know which it is talking to,and a swarm can start as one process and split later without the callers changing. A hub given a token requires it of callers (``/register`` included, so a stranger cannot join the swarm) and presents it to peers.++**Identities.** A request signed with a key (``X-Pyrtan-Key`` and its+signature, ``_meta/identity.md`` §7) is checked against the hub's+*audience*, its own id, which ``GET /api`` names. On a hub with a token,+it may do what the hub's grants say its root may (``ask``, ``chat``,+``run``, ``operate``; see :mod:`pyrtan.grants`) and nothing else; on a hub+without one it only says who is asking. A member that follows with a key+whose root holds ``run`` is trusted as one that joined with the token,+and is dropped when its grant, its note or its root's document says it+may no longer be. ``GET``/``POST /allow`` and ``POST /disallow`` (operator)+keep the grants; ``POST /identity`` takes a granted root's newer identity+document from anyone, since it is signed; ``GET+/.well-known/pyrtan/id/<56>.json`` serves the documents the hub holds.""" from __future__ import annotations@@ -49,6 +62,9 @@ from typing import Anyfrom . import __version__ from .chat import Lobby from .federation import MAX_HOPS, Federation, Remote, bootstrap_candidates, default_network, first_live_seed, parse_invite, sign+from . import identity+from .grants import Grants, ability_for, parse_may+from .identity import IdentityErrorfrom .grow import Unanswered, no_match_outcome from .intent import Trigger, read from .page import MANIFEST, PAGE, PYRTAN_JS, SERVICE_WORKER@@ -125,8 +141,11 @@ class _HubResolver:class Hub:- def __init__(self, *, name: str = "hub", poll_every: float = 10.0, token: str | None = None, local: Service | None = None, handout: Registry | None = None, unanswered: str | Path | None = None, chat: bool = False) -> None:+ def __init__(self, *, name: str = "hub", poll_every: float = 10.0, token: str | None = None, local: Service | None = None, handout: Registry | None = None, unanswered: str | Path | None = None, chat: bool = False, grants: Grants | None = None, audience: str | None = None) -> None:self.name = name+ self.grants = grants if grants is not None else Grants()+ self.grants.on_change = self._grant_changed+ self._audience = audienceself.lobby = Lobby(self) if chat else None self.handout_registry = handout # trees for trusted members only: this hub hands them out and does not run them self.poll_every = poll_every@@ -160,6 +179,42 @@ class Hub:self.federation.chat = self.lobby return self.federation+ # ------------------------------------------------------------ identities++ @property+ def audience(self) -> str | None:+ """The id a key-signed request to this hub must be signed for."""+ return self.federation.id if self.federation is not None else self._audience++ def _signer(self, method: str, path: str, body: dict[str, Any] | None, headers: dict[str, str]) -> tuple[identity.Signer | None, set[str], tuple[int, Any] | None]:+ """For a key-signed request: who signed it, what they may do here, or the refusal."""+ if not identity.has_key_signature(headers):+ return None, set(), None+ if self.audience is None:+ return None, set(), (401, {"status": "error", "reason": "unsigned", "say": "This hub has no id to check a key signature against; start it with --advertise."})+ try:+ signer = identity.verify_request(self.audience, method, path, body if method == "POST" else None, headers)+ except IdentityError as e:+ return None, set(), (401, {"status": "error", "reason": "unsigned", "say": f"The key signature does not hold: {e}."})+ may = self.grants.may(signer)+ needed = ability_for(method, path)+ if self.token and needed is not None and needed not in may:+ granted = f"it may {', '.join(sorted(may))}" if may else "this hub grants it nothing"+ return signer, may, (403, {"status": "error", "reason": "not-granted", "say": f"{identity.short(signer.root)} may not {needed} here: {granted}."})+ return signer, may, None++ def _grant_changed(self, root: str) -> None:+ """Drop the members that followed as ``root`` and may no longer be what they are."""+ fed = self.federation+ if fed is None:+ return+ for remote in [r for r in list(fed.followers.values()) if r.kind == "member" and r.identity == root]:+ note_ok = remote.note is None or identity.check_device(remote.note, root=root) is None+ revoked = remote.note is not None and (doc := self.grants.document(root)) is not None and identity.device_status(doc, remote.device or "") == "revoked"+ lost_run = remote.trusted_by == "key" and not self.grants.still(root, remote.device or "", remote.note, "run")+ if not note_ok or revoked or lost_run:+ fed.drop_follower(remote.url)+def handout_trees(self) -> dict[str, Tree]: """The trees a trusted member may run: the ones this hub holds itself, and the ones given it to hand out (``--handout``), by name. A member@@ -236,6 +291,7 @@ class Hub:def refresh_all(self) -> None: for peer in list(self.peers.values()): self.refresh(peer)+ self.grants.refresh()if self.federation is not None: self.federation.refresh_all() self.federation.announce() # only when what we hold changed@@ -481,19 +537,24 @@ class Hub:body = body or {} headers = headers or {} fed = self.federation+ signer, may, refused = self._signer(method, path, body, headers)+ if refused:+ return refused+ # The operator: the token, or a key whose root this hub grants operate.+ operator = bearer_ok(headers.get("Authorization") or headers.get("authorization"), self.token or "") or "operate" in maysigned = any(k.lower() == "x-pyrtan-signature" for k in headers)- if signed and not bearer_ok(headers.get("Authorization"), self.token or ""):+ if signed and not operator:# A hub we federate with is asking. It may ask the routed paths # and the inbox; anything else needs the operator's token. if fed is None or not signed_path(path): return 401, {"status": "error", "reason": "unauthorised", "say": "A signed request may only reach the inbox, the mailbox, the kept blobs and the routed paths."}- signer = fed.verify_request(method, path, body if method == "POST" else None, headers) if path != "/inbox" else None- if path != "/inbox" and signer is None:+ actor = fed.verify_request(method, path, body if method == "POST" else None, headers) if path != "/inbox" else None+ if path != "/inbox" and actor is None:return 401, {"status": "error", "reason": "unsigned", "say": "The signature does not hold, or this hub does not know the actor."} if method == "GET" and path == "/mailbox":- return 200, {"activities": fed.mailbox(signer or "")}+ return 200, {"activities": fed.mailbox(actor or "")}if method == "GET" and path.startswith("/kept/"):- blob = fed.kept_for(signer or "", path[len("/kept/"):])+ blob = fed.kept_for(actor or "", path[len("/kept/"):])return (200, blob) if blob else (404, {"status": "error", "reason": "not-kept", "say": f"Nothing kept here for you under {path[len('/kept/'):]!r}."}) if method == "GET" and path == "/health": return 200, self.health()@@ -508,11 +569,13 @@ class Hub:return 200, fed.actor() if method == "GET" and path == "/directory": # A private network's part only to the operator (or to anyone, on a hub with no token).- return 200, fed.directory(private=not self.token or bearer_ok(headers.get("Authorization") or headers.get("authorization"), self.token))+ return 200, fed.directory(private=not self.token or operator)if method == "GET" and path == "/reports": return 200, fed.reports_view() if method == "POST" and path == "/inbox":- return fed.handle_inbox(body, headers, trusted=bool(self.token) and bearer_ok(headers.get("Authorization") or headers.get("authorization"), self.token))+ by_token = bool(self.token) and bearer_ok(headers.get("Authorization") or headers.get("authorization"), self.token)+ by_key = signer is not None and "run" in may+ return fed.handle_inbox(body, headers, trusted=by_token or by_key, trusted_by="token" if by_token else "key" if by_key else None, signer=signer)if method == "GET" and path in ("/following", "/followers"): return 200, {path[1:]: fed.to_dict()[path[1:]]} if method == "GET" and path == "/outbox":@@ -574,6 +637,28 @@ class Hub:if not isinstance(url, str): return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"url\": \"https://hub.example\"}."} return 200, {"status": "ok", "removed": fed.unfollow(url)}+ if method == "GET" and path == "/allow":+ return 200, {"grants": self.grants.view(), "audience": self.audience}+ if method == "POST" and path in ("/allow", "/disallow"):+ who = body.get("who")+ if not isinstance(who, str):+ return 400, {"status": "error", "reason": "bad-request", "say": "Send {\"who\": \"pyr:…\" or \"name@domain\"" + (", \"may\": [\"ask\", ...]}." if path == "/allow" else "}.")}+ try:+ if path == "/disallow":+ return 200, {"status": "ok", "removed": self.grants.disallow(who)}+ grant = self.grants.allow(who, parse_may(body.get("may")))+ except IdentityError as e:+ return 400, {"status": "error", "reason": "bad-grant", "say": str(e)}+ return 200, {"status": "ok", "grant": grant.view()}+ if method == "POST" and path == "/identity":+ problem = self.grants.take_document(body.get("document"))+ if problem:+ return 400, {"status": "error", "reason": "bad-identity", "say": problem}+ return 200, {"status": "ok", "id": body["document"]["id"]}+ if method == "GET" and path.startswith("/.well-known/pyrtan/id/") and path.endswith(".json"):+ ident = identity.PREFIX + path[len("/.well-known/pyrtan/id/"):-len(".json")]+ doc = self.grants.document(ident) if len(ident) == len(identity.PREFIX) + identity.ID_LENGTH else None+ return (200, doc) if doc else (404, {"status": "error", "reason": "no-identity", "say": "This hub holds no document for that identity."})if method == "GET" and path == "/capabilities": return 200, self.capabilities() if method == "GET" and path == "/gaps":@@ -595,12 +680,12 @@ class Hub:if method == "GET" and path == "/pyrtan.js": return 200, ("text/javascript; charset=utf-8", PYRTAN_JS) if method == "GET" and path == "/api":- endpoints = ["/health", "/peers", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/watches", "/register"]+ endpoints = ["/health", "/peers", "/capabilities", "/intent", "/run", "/resume", "/gaps", "/unanswered", "/watches", "/register", "/allow", "/disallow", "/identity", "/.well-known/pyrtan/id/{id}.json"]if self.lobby is not None: endpoints += ["/chat", "/chat/start", "/chat/poll", "/chat/say", "/chat/next", "/chat/leave"] if fed is not None: endpoints += ["/actor", "/inbox", "/mailbox", "/directory", "/reports", "/follow", "/unfollow", "/networks", "/join", "/leave", "/listed", "/following", "/followers", "/outbox", "/keep", "/fetch", "/kept/{key}"]- return 200, {"pyrtan": "hub", "name": self.name, "version": __version__, "auth": "bearer" if self.token else "none", "federation": fed.id if fed else None, "chat": self.lobby is not None, "endpoints": endpoints}+ return 200, {"pyrtan": "hub", "name": self.name, "version": __version__, "auth": "bearer" if self.token else "none", "federation": fed.id if fed else None, "chat": self.lobby is not None, "audience": self.audience, "grants": len(self.grants), "endpoints": endpoints}if method == "POST" and path == "/register": url = body.get("url") if not isinstance(url, str) or not url.startswith(("http://", "https://")):@@ -689,10 +774,22 @@ def serve_hub(unanswered: str | Path | None = None, watch: bool = False, chat: bool = False,+ allow: list[str] | None = None,+ grants: str | None = None,) -> tuple[Hub, Server]:+ """Start a hub. ``allow`` holds ``WHO=ABILITIES`` grants, made before it answers+ anything (a name is resolved now; IdentityError if it cannot be); ``grants`` is+ where the list is kept (default: beside ``state``, else in memory)."""+ from .grants import parse_spec+ specs = [parse_spec(a) for a in allow or []]+ keep = grants or (str(Path(state).with_name("grants.json")) if state else None)+ granted = Grants(keep)+ for who, may in specs:+ granted.allow(who, may)local = Service(Registry.from_paths(trees), name=f"{name}", env=env, gaps=gaps, egress=egress, watch=watch) if trees else None- hub = Hub(name=name, poll_every=poll_every, token=token, local=local, handout=Registry.from_paths(handout) if handout else None, unanswered=unanswered, chat=chat)- server = Server(hub.route, host, port, quiet, token=token, cors=cors, federated=federate or bool(advertise)).start()+ hub = Hub(name=name, poll_every=poll_every, token=token, local=local, handout=Registry.from_paths(handout) if handout else None, unanswered=unanswered, chat=chat, grants=granted)+ server = Server(hub.route, host, port, quiet, token=token, cors=cors, federated=federate or bool(advertise), keyed=True).start()+ hub._audience = (advertise or server.url).rstrip("/")if federate or advertise or join: hub.federate(advertise or server.url, secret=federation_secret, state=state, open_follows=open_follows, listed=listed, store=store or (str(Path(state).with_name("store")) if state else None), replicas=replicas, report_gaps=report_gaps) for url in peers or []: diff --git a/src/pyrtan/identity.py b/src/pyrtan/identity.py index 671a4ce..8a7031c 100644 --- a/src/pyrtan/identity.py +++ b/src/pyrtan/identity.py@@ -42,6 +42,7 @@ VERSION_ED25519 = 1VERSIONS = {VERSION_ED25519: 32} CHECKSUM_TAG = b".pyrtan id" DOC_TAG = b"pyrtan-doc-v1\n"+REQUEST_TAG = "pyrtan-req-v1"ID_LENGTH = 56 WINDOW = 300 # seconds a document's time may run ahead of ours YEAR = 365 * 24 * 3600 # the longest a device note may last@@ -511,6 +512,83 @@ def check_any(doc: Any, *, now: float | None = None) -> tuple[str, str | None]:return "unknown", "not a pyrtan identity document, device note or name claim"+# ---------------------------------------------------------------- signed requests+#+# A request made as an identity carries X-Pyrtan-Key (the signing key, a+# root or a device), X-Pyrtan-Date, X-Pyrtan-Key-Signature (Ed25519 over the+# message below) and, for a device, X-Pyrtan-Device (its note, as base64url+# canonical JSON). The message is the HMAC one with a domain string and the+# *audience*, the receiving hub's own id, in front: a request signed for one+# hub cannot be replayed at another.+++def request_message(audience: str, method: str, path: str, date: str, body: Any) -> bytes:+ digest = hashlib.sha256(canonical(body) if body is not None else b"").hexdigest()+ return "\n".join([REQUEST_TAG, audience.rstrip("/"), method.upper(), path, date, digest]).encode("utf-8")+++def sign_request(key: Key, audience: str, method: str, path: str, body: Any, *, note: dict[str, Any] | None = None,+ date: float | None = None) -> dict[str, str]:+ """The headers that make a request ``key``'s; ``note`` when ``key`` is a device."""+ date_text = str(int(time.time() if date is None else date))+ headers = {+ "X-Pyrtan-Key": key.id, "X-Pyrtan-Date": date_text,+ "X-Pyrtan-Key-Signature": _b64(key.sign(request_message(audience, method, path, date_text, body))),+ }+ if note is not None:+ if note.get("device") != key.id:+ raise IdentityError(f"the note is for {short(note.get('device', '?'))}, not this key")+ headers["X-Pyrtan-Device"] = _b64(canonical(note))+ return headers+++@dataclass(frozen=True)+class Signer:+ """Who signed a request: the key, the root it speaks for, and the device note if it is a device."""+ key: str+ root: str+ note: dict[str, Any] | None = None+++def has_key_signature(headers: dict[str, str] | None) -> bool:+ return any(k.lower() == "x-pyrtan-key-signature" for k in (headers or {}))+++def verify_request(audience: str, method: str, path: str, body: Any, headers: dict[str, str] | None, *,+ now: float | None = None) -> Signer:+ """The signer of a request, or IdentityError saying why there is none. Checks the+ signature, the time, and the device note (signed by its root, current, for this key);+ whether the root may do anything is the caller's question."""+ h = {k.lower(): v for k, v in (headers or {}).items()}+ key, date, signature = h.get("x-pyrtan-key", ""), h.get("x-pyrtan-date", ""), h.get("x-pyrtan-key-signature", "")+ if not key or not signature:+ raise IdentityError("no key signature")+ key = normal(key)+ if not date.isdigit():+ raise IdentityError("no date")+ now = time.time() if now is None else now+ if abs(now - int(date)) > WINDOW:+ raise IdentityError("signature too old")+ try:+ raw = _unb64(signature)+ except (IdentityError, ValueError):+ raise IdentityError("the signature is not base64url") from None+ if not verify(key, request_message(audience, method, path, date, body), raw):+ raise IdentityError(f"the signature is not {short(key)}'s, for {audience}")+ if "x-pyrtan-device" not in h:+ return Signer(key, key)+ try:+ note = json.loads(_unb64(h["x-pyrtan-device"]).decode("utf-8"))+ except (IdentityError, ValueError):+ raise IdentityError("X-Pyrtan-Device is not a base64url note") from None+ problem = check_device(note, now=now)+ if problem:+ raise IdentityError(f"device note: {problem}")+ if note["device"] != key:+ raise IdentityError(f"the device note is for {short(note['device'])}, not the signing key")+ return Signer(key, note["root"], note)++# ---------------------------------------------------------------- where the CLI keeps them diff --git a/src/pyrtan/service.py b/src/pyrtan/service.py index cfdbb03..d79ecca 100644 --- a/src/pyrtan/service.py +++ b/src/pyrtan/service.py@@ -66,8 +66,14 @@ from .transport import Transport, UrllibTransportMAX_BODY = 1 << 20-# What answers without a token: the console, its index, and liveness.-PUBLIC_PATHS = frozenset({"/", "/index.html", "/console", "/api", "/health", "/actor", "/directory", "/reports", "/manifest.webmanifest", "/sw.js", "/pyrtan.js"})+# What answers without a token: the console, its index, liveness, and a pushed+# identity document (which proves itself by its signature).+PUBLIC_PATHS = frozenset({"/", "/index.html", "/console", "/api", "/health", "/actor", "/directory", "/reports", "/manifest.webmanifest", "/sw.js", "/pyrtan.js", "/identity"})+++def public_path(path: str) -> bool:+ """What answers without a token: the paths above, and ``/.well-known/`` (identity documents, signed)."""+ return path in PUBLIC_PATHS or path.startswith("/.well-known/")# On a federated hub, a request carrying a signature reaches the route, # which verifies it (see :mod:`pyrtan.federation`). SIGNED_PATHS = frozenset({"/inbox", "/run", "/intent", "/resume", "/capabilities", "/mailbox"})@@ -76,6 +82,8 @@ SIGNED_PATHS = frozenset({"/inbox", "/run", "/intent", "/resume", "/capabilitiesdef signed_path(path: str) -> bool: """Whether a signed (federated) request may reach this path at all.""" return path in SIGNED_PATHS or path.startswith("/kept/")+# Headers a browser at another origin may send: federation's HMAC and an identity's key.+SIGNATURE_HEADERS = ("X-Pyrtan-Actor", "X-Pyrtan-Date", "X-Pyrtan-Signature", "X-Pyrtan-Key", "X-Pyrtan-Key-Signature", "X-Pyrtan-Device")# What /health says to a caller without a token, when one is required. PUBLIC_HEALTH_KEYS = ("status", "name", "peers", "up", "stale", "down", "uptime_s")@@ -279,7 +287,7 @@ def bearer_ok(header: str | None, token: str) -> bool:return scheme.lower() == "bearer" and hmac.compare_digest(value.strip().encode("utf-8"), token.encode("utf-8"))-def make_handler(route: Route, quiet: bool = False, token: str | None = None, cors: list[str] | None = None, federated: bool = False):+def make_handler(route: Route, quiet: bool = False, token: str | None = None, cors: list[str] | None = None, federated: bool = False, keyed: bool = False):token = token or None origins = [o.rstrip("/") for o in (cors or []) if o] any_origin = "*" in origins@@ -305,7 +313,7 @@ def make_handler(route: Route, quiet: bool = False, token: str | None = None, coreturn self.send_header("Access-Control-Allow-Origin", origin) self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")- self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")+ self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type, " + ", ".join(SIGNATURE_HEADERS))self.send_header("Access-Control-Max-Age", "600") self.send_header("Vary", "Origin")@@ -319,10 +327,12 @@ def make_handler(route: Route, quiet: bool = False, token: str | None = None, codef _authorised(self, path: str) -> bool: """True if the request may proceed; otherwise a 401 has been sent."""- if token is None or path in PUBLIC_PATHS or bearer_ok(self.headers.get("Authorization"), token):+ if token is None or public_path(path) or bearer_ok(self.headers.get("Authorization"), token):return True if federated and signed_path(path) and self.headers.get("X-Pyrtan-Signature"): return True # the route verifies the signature+ if keyed and self.headers.get("X-Pyrtan-Key-Signature"):+ return True # the route verifies the key and asks its grantsself._send(401, {"status": "error", "reason": "unauthorised", "say": "This Pyrtan needs a token: send Authorization: Bearer <token>."}, extra={"WWW-Authenticate": 'Bearer realm="pyrtan"'}) return False@@ -393,8 +403,8 @@ class Server:``token`` requires ``Authorization: Bearer`` on everything but the public paths; ``cors`` lists the browser origins allowed to call (``*`` for any)."""- def __init__(self, route: Route, host: str = "127.0.0.1", port: int = 0, quiet: bool = False, *, token: str | None = None, cors: list[str] | None = None, federated: bool = False) -> None:- self.httpd = ThreadingHTTPServer((host, port), make_handler(route, quiet, token, cors, federated))+ def __init__(self, route: Route, host: str = "127.0.0.1", port: int = 0, quiet: bool = False, *, token: str | None = None, cors: list[str] | None = None, federated: bool = False, keyed: bool = False) -> None:+ self.httpd = ThreadingHTTPServer((host, port), make_handler(route, quiet, token, cors, federated, keyed))self.httpd.daemon_threads = True self.thread: threading.Thread | None = None diff --git a/tests/test_grants.py b/tests/test_grants.py new file mode 100644 index 0000000..df1a67c --- /dev/null +++ b/tests/test_grants.py@@ -0,0 +1,317 @@+"""Signed requests, grants, and a hub that answers identities (_meta/identity.md §7)."""++import base64+import json+import secrets+import time++import pytest++pytest.importorskip("cryptography")++from pyrtan import identity # noqa: E402+from pyrtan.cli import main # noqa: E402+from pyrtan.federation import sign # noqa: E402+from pyrtan.grants import Grants, ability_for, parse_may, parse_spec # noqa: E402+from pyrtan.hub import serve_hub # noqa: E402+from pyrtan.identity import IdentityError # noqa: E402+from pyrtan.service import http_json # noqa: E402++ECHO = """+name = "echo"+description = "echoes"+start = "say"++[[triggers]]+pattern = "echo {text}"++[inputs.text]+type = "str"++[nodes.say]+kind = "done"+say = "echo: {{ inputs.text }}"+"""+AUD = "https://hub.example"+++@pytest.fixture+def root():+ return identity.Key.generate()+++@pytest.fixture+def phone():+ return identity.Key.generate()+++# ---------------------------------------------------------------- signed requests+++def test_a_root_signs_for_itself(root):+ body = {"text": "echo hi"}+ h = identity.sign_request(root, AUD, "POST", "/intent", body)+ signer = identity.verify_request(AUD, "post", "/intent", body, h)+ assert signer == identity.Signer(root.id, root.id, None)+ assert identity.verify_request(AUD + "/", "POST", "/intent", body, h).root == root.id # a trailing slash is the same hub+++@pytest.mark.parametrize("change, reason", [+ (lambda a: {**a, "audience": "https://other.example"}, "for https://other.example"),+ (lambda a: {**a, "path": "/run"}, "signature is not"),+ (lambda a: {**a, "method": "GET"}, "signature is not"),+ (lambda a: {**a, "body": {"text": "echo bye"}}, "signature is not"),+ (lambda a: {**a, "now": time.time() + 1000}, "too old"),+])+def test_a_request_signature_binds_everything(root, change, reason):+ args = {"audience": AUD, "method": "POST", "path": "/intent", "body": {"text": "echo hi"}, "now": None}+ h = identity.sign_request(root, AUD, "POST", "/intent", args["body"])+ args = change(args)+ with pytest.raises(IdentityError, match=reason):+ identity.verify_request(args["audience"], args["method"], args["path"], args["body"], h, now=args["now"])+++def test_a_device_signs_with_its_note(root, phone):+ note = identity.make_device(root, phone.id, ["ask"])+ h = identity.sign_request(phone, AUD, "GET", "/capabilities", None, note=note)+ signer = identity.verify_request(AUD, "GET", "/capabilities", None, h)+ assert (signer.key, signer.root, signer.note) == (phone.id, root.id, note)+ with pytest.raises(IdentityError, match="not this key"):+ identity.sign_request(root, AUD, "GET", "/", None, note=note)+ # A note for another key, an altered note, or a note that is not one.+ other = identity.Key.generate()+ forged = {**h, "X-Pyrtan-Device": base64.urlsafe_b64encode(json.dumps(identity.make_device(root, other.id, ["ask"])).encode()).decode().rstrip("=")}+ with pytest.raises(IdentityError, match="not the signing key"):+ identity.verify_request(AUD, "GET", "/capabilities", None, forged)+ widened = {**h, "X-Pyrtan-Device": base64.urlsafe_b64encode(json.dumps({**note, "may": ["operate"]}).encode()).decode().rstrip("=")}+ with pytest.raises(IdentityError, match="device note"):+ identity.verify_request(AUD, "GET", "/capabilities", None, widened)+ with pytest.raises(IdentityError, match="not a base64url note"):+ identity.verify_request(AUD, "GET", "/capabilities", None, {**h, "X-Pyrtan-Device": "!!"})+++def test_no_signature():+ with pytest.raises(IdentityError, match="no key signature"):+ identity.verify_request(AUD, "GET", "/", None, {})+ assert not identity.has_key_signature({"X-Pyrtan-Signature": "x"})+ assert identity.has_key_signature({"x-pyrtan-key-signature": "x"})+++# ---------------------------------------------------------------- grants+++def test_abilities_and_specs():+ assert ability_for("POST", "/intent") == "ask" and ability_for("GET", "/capabilities") == "ask"+ assert ability_for("POST", "/chat/start") == "chat"+ assert ability_for("POST", "/follow") == "operate" and ability_for("GET", "/peers") == "operate"+ assert ability_for("POST", "/inbox") is None and ability_for("POST", "/identity") is None+ assert ability_for("GET", "/.well-known/pyrtan/id/x.json") is None and ability_for("GET", "/health") is None+ assert parse_may("run, ask") == ["ask", "run"] and parse_may(["chat"]) == ["chat"]+ with pytest.raises(IdentityError):+ parse_may("fly")+ with pytest.raises(IdentityError):+ parse_may("")+ assert parse_spec("alice@pyrtan.com=ask,chat") == ("alice@pyrtan.com", ["ask", "chat"])+ with pytest.raises(IdentityError):+ parse_spec("alice@pyrtan.com")+++def test_grants_by_id_and_device(tmp_path, root, phone):+ g = Grants(tmp_path / "grants.json")+ g.allow(root.id, ["ask", "run"])+ assert g.may(identity.Signer(root.id, root.id)) == {"ask", "run"}+ note = identity.make_device(root, phone.id, ["run", "chat"])+ assert g.may(identity.Signer(phone.id, root.id, note)) == {"run"} # the note caps the grant+ assert g.may(identity.Signer(phone.id, phone.id)) == set() # a key nobody granted+ # A newer document that revokes the phone, from anyone.+ doc = identity.make_identity(root, revoked=[phone.id])+ assert g.take_document(doc) is None and g.document(root.id) == doc+ assert g.may(identity.Signer(phone.id, root.id, note)) == set()+ assert g.take_document(identity.make_identity(root, updated=doc["updated"] - 10)) is None and g.document(root.id) == doc # older: kept ours+ assert "signature" in g.take_document({**doc, "revoked": [], "updated": doc["updated"] + 5})+ assert "grants that identity nothing" in g.take_document(identity.make_identity(phone))+ # Kept across a restart.+ again = Grants(tmp_path / "grants.json")+ assert again.may(identity.Signer(root.id, root.id)) == {"ask", "run"} and again.document(root.id) == doc+ assert again.disallow(root.id) and not again.disallow(root.id)+ assert Grants(tmp_path / "grants.json").may(identity.Signer(root.id, root.id)) == set()+++def test_a_grant_by_name_resolves_once_and_reads_documents(root):+ claim = identity.make_name(root, "alice@example.org")+ doc = identity.make_identity(root, routes=["hub.example"])+ asked = []+ served = {"https://example.org/.well-known/pyrtan/name/alice.json": claim,+ f"https://example.org/.well-known/pyrtan/id/{root.id[4:]}.json": doc}++ def fetch(url):+ asked.append(url)+ return (200, served[url]) if url in served else (404, None)++ g = Grants(fetch=fetch)+ grant = g.allow("Alice@example.org", ["chat"])+ assert (grant.id, grant.name, grant.doc) == (root.id, "alice@example.org", doc)+ # The name is the pin: the domain pointing it elsewhere moves nothing here.+ served["https://example.org/.well-known/pyrtan/name/alice.json"] = identity.make_name(identity.Key.generate(), "alice@example.org")+ asked.clear()+ assert g.allow("alice@example.org", ["chat", "ask"]).id == root.id+ assert not any("/name/" in u for u in asked)+ # The hourly refresh reads the name's domain and the document's routes.+ newer = identity.make_identity(root, routes=["hub.example"], updated=doc["updated"] + 1)+ served[f"https://hub.example/.well-known/pyrtan/id/{root.id[4:]}.json"] = newer+ g.refresh(now=time.time() + 4000)+ assert g.document(root.id) == newer+ assert g.disallow("alice@example.org")+++# ---------------------------------------------------------------- a hub+++class Caller:+ def __init__(self, url, key, note=None):+ self.url, self.key, self.note = url.rstrip("/"), key, note+ self.audience = http_json("GET", f"{self.url}/api")[1]["audience"]++ def __call__(self, method, path, body=None):+ if method == "POST" and body is None:+ body = {}+ return http_json(method, f"{self.url}{path}", body, headers=identity.sign_request(self.key, self.audience, method, path, body, note=self.note))+++@pytest.fixture+def hub(tmp_path):+ (tmp_path / "t").mkdir()+ (tmp_path / "t" / "echo.toml").write_text(ECHO)+ h, srv = serve_hub(port=0, quiet=True, name="solo", trees=[tmp_path / "t"], federate=True, token="tok", chat=True, poll_every=60, state=str(tmp_path / "state.json"))+ yield h, srv+ h.stop()+ srv.stop()+++def test_a_hub_answers_what_it_grants(hub, root, phone):+ h, srv = hub+ as_root = Caller(srv.url, root)+ assert as_root.audience == h.federation.id == srv.url.rstrip("/")+ # Nothing granted yet: the signature holds, the grant does not.+ status, body = as_root("POST", "/intent", {"text": "echo hi"})+ assert status == 403 and body["reason"] == "not-granted" and "grants it nothing" in body["say"]+ h.grants.allow(root.id, ["ask"])+ status, body = as_root("POST", "/intent", {"text": "echo hi"})+ assert status == 200 and body["say"] == "echo: hi"+ assert as_root("GET", "/capabilities")[0] == 200+ assert as_root("GET", "/peers")[0] == 403 # operate+ assert as_root("POST", "/chat/start", {"want": "bot"})[0] == 403+ # A device asks with what its note and the grant both allow.+ as_phone = Caller(srv.url, phone, identity.make_device(root, phone.id, ["ask", "chat"]))+ assert as_phone("POST", "/intent", {"text": "echo phone"})[1]["say"] == "echo: phone"+ h.grants.allow(root.id, ["ask", "chat"])+ assert as_phone("POST", "/chat/start", {"want": "bot"})[0] == 200+ # A bad signature is refused, whatever the grants.+ bad = identity.sign_request(root, "https://elsewhere.example", "POST", "/intent", {"text": "echo x"})+ status, body = http_json("POST", f"{srv.url}/intent", {"text": "echo x"}, headers=bad)+ assert status == 401 and "for" in body["say"]+ # The token still works as it did.+ assert http_json("GET", f"{srv.url}/peers", token="tok")[0] == 200+++def test_operate_manages_grants(hub, root, phone, capsys, monkeypatch, tmp_path):+ h, srv = hub+ h.grants.allow(root.id, ["operate"])+ as_root = Caller(srv.url, root)+ status, body = as_root("POST", "/allow", {"who": phone.id, "may": ["ask"]})+ assert status == 200 and body["grant"]["may"] == ["ask"]+ assert {g["id"] for g in as_root("GET", "/allow")[1]["grants"]} == {root.id, phone.id}+ assert as_root("POST", "/allow", {"who": phone.id, "may": ["fly"]})[0] == 400+ assert as_root("POST", "/disallow", {"who": phone.id})[1]["removed"] is True+ assert as_root("GET", "/peers")[0] == 200+ # The CLI signs the same way, as the root key in $PYRTAN_IDENTITY.+ me = tmp_path / "me"+ root.save(me / "root.key")+ (me / "identity.json").write_text(json.dumps(identity.make_identity(root)))+ monkeypatch.setenv("PYRTAN_IDENTITY", str(me))+ assert main(["allow", phone.id, "--may", "ask,chat", "--at", srv.url, "--sign"]) == 0+ assert main(["allowed", "--at", srv.url, "--sign"]) == 0+ out = capsys.readouterr().out+ assert f"grants {phone.id}" in out and "may ask,chat" in out+ assert main(["id", "push", "--at", srv.url]) == 0+ assert h.grants.document(root.id) is not None+ assert main(["disallow", phone.id, "--at", srv.url, "--sign"]) == 0+ assert main(["allowed", "--at", srv.url]) == 3 # neither token nor key+++def test_documents_are_pushed_and_served(hub, root):+ h, srv = hub+ doc = identity.make_identity(root, routes=["hub.example"])+ status, body = http_json("POST", f"{srv.url}/identity", {"document": doc})+ assert status == 400 and "grants that identity nothing" in body["say"]+ h.grants.allow(root.id, ["chat"])+ assert http_json("POST", f"{srv.url}/identity", {"document": doc})[0] == 200+ assert http_json("GET", f"{srv.url}/.well-known/pyrtan/id/{root.id[4:]}.json") == (200, doc)+ assert http_json("GET", f"{srv.url}/.well-known/pyrtan/id/nope.json")[0] == 404+++def follow_as(srv_url, key, note=None, *, actor=None, audience=None):+ """A member's Follow, HMAC-signed with its pair secret as always, and signed with ``key``."""+ actor = actor or key.id+ pair = secrets.token_hex(32)+ hub = srv_url.rstrip("/")+ activity = {"id": f"{actor}/activities/follow", "actor": actor, "published": "now", "type": "Follow", "object": hub, "secret": pair, "inbox": "mailbox", "name": "phone"}+ headers = sign(pair, actor, "POST", "/inbox", activity)+ headers.update(identity.sign_request(key, audience or hub, "POST", "/inbox", activity, note=note))+ return http_json("POST", f"{hub}/inbox", activity, headers=headers)+++def test_members_follow_as_an_identity(hub, root, phone):+ h, srv = hub+ fed = h.federation+ note = identity.make_device(root, phone.id, ["run"])+ # Granted nothing: a keeper, but it says who it is.+ status, body = follow_as(srv.url, phone, note)+ assert status == 202 and body["trusted"] is False and body["identity"] == root.id+ # Granted run: trusted, as if it had joined with the token.+ h.grants.allow(root.id, ["run"])+ status, body = follow_as(srv.url, phone, note)+ assert status == 202 and body["trusted"] is True+ member = fed.followers[phone.id]+ assert (member.kind, member.trusted_by, member.identity, member.device) == ("member", "key", root.id, phone.id)+ # A member whose id is a key must sign with that key.+ other = identity.Key.generate()+ status, body = follow_as(srv.url, other, actor=phone.id)+ assert status == 401 and "signs its follow with that key" in body["say"]+ # Revoking the phone in a pushed document drops it.+ doc = identity.make_identity(root, revoked=[phone.id])+ assert http_json("POST", f"{srv.url}/identity", {"document": doc})[0] == 200+ assert phone.id not in fed.followers+ # Taking back run drops a member trusted by key, and leaves one trusted by token.+ laptop = identity.Key.generate()+ assert follow_as(srv.url, laptop)[1]["trusted"] is False+ h.grants.allow(laptop.id, ["run"])+ assert follow_as(srv.url, laptop)[1]["trusted"] is True+ h.grants.disallow(laptop.id)+ assert laptop.id not in fed.followers+++def test_a_hub_with_no_token_only_asks_who(tmp_path, root):+ h, srv = serve_hub(port=0, quiet=True, name="open", poll_every=60)+ try:+ assert http_json("GET", f"{srv.url}/api")[1]["audience"] == srv.url.rstrip("/")+ as_root = Caller(srv.url, root)+ assert as_root("GET", "/peers")[0] == 200 # open hub: a key is who, not a permission+ bad = identity.sign_request(root, "https://elsewhere.example", "GET", "/peers", None)+ assert http_json("GET", f"{srv.url}/peers", headers=bad)[0] == 401+ finally:+ h.stop()+ srv.stop()+++def test_hub_flags(tmp_path, root, capsys):+ from pyrtan.grants import Grants as G+ h, srv = serve_hub(port=0, quiet=True, name="flags", token="tok", poll_every=60, allow=[f"{root.id}=ask,chat"], state=str(tmp_path / "state.json"))+ try:+ assert h.grants.may(identity.Signer(root.id, root.id)) == {"ask", "chat"}+ assert G(tmp_path / "grants.json").may(identity.Signer(root.id, root.id)) == {"ask", "chat"}+ finally:+ h.stop()+ srv.stop()+ with pytest.raises(IdentityError):+ serve_hub(port=0, quiet=True, allow=["nobody@127.0.0.1:9=ask"])