pyrtan / sourcev0.14.0 · main

Identity, first step: pyr: identifiers, signed documents, and pyrtan id

Claude · 2026-10-27 · c6fbeaed33d4aa618052cd72ac59e5ca2707eea8 · parents 92a5774

src/pyrtan/identity.py encodes and checks pyr:<56> identifiers (Ed25519 key, SHA3-256 checksum, version byte; standard library only), parses the three forms (pyr:<key>, pyr:<key>@locator, name@domain), and signs and checks the identity document, device notes and name claims, with cryptography as the optional pyrtan[identity] extra.

pyrtan id new, show, check, device add, revoke, route add|remove and name keep a root key (mode 600) and its identity document under $PYRTAN_IDENTITY or ~/.config/pyrtan/identity.

Tested against RFC 8032's vectors and the design note's vector; CI installs the extra, and the suite passes without it. The design note records what is built; names at pyrtan.com and grants on hubs come next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rD7jgoLgxA3qCSMVWdoDv

Changes

 .github/workflows/catalog.yml |   2 +-
 .github/workflows/image.yml   |   2 +-
 .github/workflows/pages.yml   |   2 +-
 README.md                     |  31 +++-
 _meta/identity.md             |  32 ++--
 pyproject.toml                |   1 +
 src/pyrtan/cli.py             | 180 +++++++++++++++++++++-
 src/pyrtan/identity.py        | 522 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 tests/test_identity.py        | 318 ++++++++++++++++++++++++++++++++++++++
 uv.lock                       | 167 +++++++++++++++++++-
 10 files changed, 1238 insertions(+), 19 deletions(-)
diff --git a/.github/workflows/catalog.yml b/.github/workflows/catalog.yml
index d1f16e9..f07e433 100644
--- a/.github/workflows/catalog.yml
+++ b/.github/workflows/catalog.yml
@@ -25,7 +25,7 @@ jobs:
with: fetch-depth: 0 - uses: astral-sh/setup-uv@v5
- - run: uv sync
+ - run: uv sync --extra identity # the identity tests sign and verify
- run: uv run pytest -q - run: uv run pyrtan check examples catalog --quiet - run: uv run python site/gate.py --base "origin/${{ github.base_ref }}" --author "${{ github.event.pull_request.user.login }}" diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index 7ead3a1..f9ea120 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml
@@ -20,7 +20,7 @@ jobs:
steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5
- - run: uv sync
+ - run: uv sync --extra identity # the identity tests sign and verify
- run: uv run pytest -q - run: uv run pyrtan check examples diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index f8454fe..fb2a856 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml
@@ -33,7 +33,7 @@ jobs:
with: fetch-depth: 0 # every branch and the whole history, for /source - uses: astral-sh/setup-uv@v5
- - run: uv sync
+ - run: uv sync --extra identity # the identity tests sign and verify
- run: uv run pytest -q - run: uv run site/build.sh out - uses: actions/upload-pages-artifact@v3 diff --git a/README.md b/README.md index 884b622..964222b 100644 --- a/README.md +++ b/README.md
@@ -55,6 +55,8 @@ pyrtan --version
Trees that drive a browser need the one optional extra, Playwright: `pip install 'pyrtan[browser]'` and `playwright install chromium` (or point `PYRTAN_BROWSER` at a Chromium you have).
+Signing and checking identities (`pyrtan id`) needs the other,
+`cryptography`: `pip install 'pyrtan[identity]'`.
From a checkout, `uv sync` then `uv run pyrtan …`, and `uv run pytest`.
@@ -174,6 +176,7 @@ hosted box is not lent out. The design is in [`_meta/io.md`](_meta/io.md).
| `pyrtan pull NAME[@VERSION]` | Fetches a tree from a catalog into your trees folder, verified against the catalog's index. | | `pyrtan index DIR` | Builds a catalog `index.json` from a directory of trees; what a catalog's CI runs. | | `pyrtan console --out FILE` | Writes the console as one static HTML file, to host anywhere. |
+| `pyrtan id new` | Makes your identity: a root key and the signed document hubs will hold. `id device add pyr:…` delegates to a device key, `id revoke` takes it back, `id route add HOST` says where you can be reached, `id name alice@domain` signs a name claim for a domain to serve, `id check` checks an identifier or any of those documents, `id show` shows them. |
`serve` and `hub` take `--token` (or `$PYRTAN_TOKEN`) to require `Authorization: Bearer` on everything but `/health` and the console, and
@@ -307,6 +310,27 @@ sentence carries `via: [the hub it crossed]`, and the console's Peers tab
shows who follows whom. The design is in [`_meta/federation.md`](_meta/federation.md).
+## Identity
+
+A person, a hub or a device is a key, and its identity is that key
+written out: `pyr:` and 56 characters, which anyone can check a
+signature against without asking anybody. `pyr:<key>@host` (a hub, an
+`.onion`) says where it can be reached; `alice@pyrtan.com` is a name a
+domain vouches for. No domain owns an identity, and none grants
+anything by it: each hub will keep its own list of who may do what.
+
+```
+pyrtan id new # your root key, ~/.config/pyrtan/identity/
+pyrtan id device add pyr:… --may chat,run --label phone
+pyrtan id name alice@pyrtan.com --github <your numeric GitHub id>
+pyrtan id check pyr:…@hub.example
+```
+
+This is the first step: identifiers, the signed documents (the identity
+document, device notes, name claims) and the command. Names at
+pyrtan.com and grants on hubs come next. The design is in
+[`_meta/identity.md`](_meta/identity.md).
+
## The catalog Trees other people wrote are at <https://pyrtan.com/catalog/>, a directory
@@ -552,7 +576,8 @@ slot refused. The whole set of rules is in
``` src/pyrtan/ the package: tree, nlp, intent, template, conditions, extract, html, transport, runtime, trace, registry, gaps, grow,
- service, hub, federation, page (the console), catalog, cli
+ service, hub, federation, identity, page (the console),
+ catalog, cli
examples/ trees that run against real public APIs, and offline against tests/fixtures/examples.json tests/ pytest; a swarm of two services behind a hub runs on localhost
@@ -580,7 +605,9 @@ topic or a serial line speaks), `pyrtan grow` (the branch a gap asks
for, drafted by rules), and the chat lobby (strangers on the hub and the hubs it follows, and bots made of trees) are built and tested; a swarm can require a token, keep its gaps and unanswered sentences, refuse private addresses
-and its own box, and follow other swarms. Not yet built: hosting.
+and its own box, and follow other swarms. Identities (keys, device notes
+and name claims, `pyrtan id`) are built as far as their first step;
+names at pyrtan.com and grants on hubs are not. Not yet built: hosting.
See [`_meta/design.md`](_meta/design.md) for the runtime's design and [`_meta/platform.md`](_meta/platform.md) for pyrtan.com. diff --git a/_meta/identity.md b/_meta/identity.md index 8052311..2e4f77e 100644 --- a/_meta/identity.md +++ b/_meta/identity.md
@@ -2,10 +2,10 @@
| | | |---|---|
-| **Status** | **Proposed.** Nothing here is built. This note settles the format and the rules before any code; §10 says in what order the code would come. |
+| **Status** | **First step built** (§10): identifiers, the three documents, signing and checking, and `pyrtan id`. Names at pyrtan.com (§6) and everything on the wire (§7) are proposed, not built. |
| **Date** | 2026-10-27 | | **Decision** | A person (or a hub, or a device) is a public key. The identifier is that key, written so that anyone can check a signature from it without asking anybody. Domains, hubs and onion services are where an identity can be *reached* and what it can be *called*, never what it *is*: `pyr:<key>` is the identity, `pyr:<key>@host` says where to find it, and `alice@pyrtan.com` is a name a domain vouches for. pyrtan.com hands out names by pull request, as one name provider among any number. An identity grants nothing by itself: each hub keeps its own list of who may do what. |
-| **Where** | Proposed: `src/pyrtan/identity.py` (the encoding, documents, signing and checking), `src/pyrtan/federation.py` and `hub.py` (signed requests, grants), `src/pyrtan/page.py` (the console's device key), `site/names/` and `site/gate.py` (pyrtan.com's names), `/.well-known/pyrtan/` on any domain. |
+| **Where** | Built: `src/pyrtan/identity.py` (the encoding, documents, signing and checking), `pyrtan id` in `src/pyrtan/cli.py`, `tests/test_identity.py`. Proposed: `src/pyrtan/federation.py` and `hub.py` (signed requests, grants), `src/pyrtan/page.py` (the console's device key), `site/names/` and `site/gate.py` (pyrtan.com's names), `/.well-known/pyrtan/` on any domain. |
## 0. The brief
@@ -215,6 +215,9 @@ or someone registering at it, from pointing a name at a key whose holder
never claimed it. The domain's part is serving the file; the key's part is signing it; both have to agree.
+`pyrtan id name alice@example.org` signs a claim with your root key, for
+the domain to serve.
+
**Pinning.** The first identity a name resolves to is remembered (in the hub's state file, the console's storage, `~/.config/pyrtan/`). A later answer naming a different identity is not followed: it is shown beside
@@ -365,18 +368,25 @@ hub that checks Alice with a shared secret can also forge Alice.
## 10. Sequencing
-- **First: the identifier and the documents.** `identity.py`: encode,
- decode, checksum, canonical document signing and checking; `pyrtan id
- new`, `show`, `check`, `device add`, `revoke`; the root key in
- `~/.config/pyrtan/identity/`. Tests with RFC 8032's vectors and the
- vector in §2, and conformance vectors (`tests/conformance/`) so the
- console's checking agrees with Python's.
+- **First, built: the identifier and the documents.** `identity.py`:
+ encode, decode, checksum, parse the three forms, canonical document
+ signing and checking, reissuing a document strictly newer; `pyrtan id
+ new`, `show`, `check`, `device add`, `revoke`, `route add|remove`, and
+ `name` (signs a claim, ready for the second step); the root key in
+ `$PYRTAN_IDENTITY`, else `~/.config/pyrtan/identity/root.key`, mode
+ 600, beside `identity.json`. Tested with RFC 8032's vectors and the
+ vector in §2; CI installs the extra so signing is tested, and the
+ suite still passes without it.
- **Second: names.** `site/names/`, the gate's checks, the build's `/.well-known/pyrtan/` output, `pyrtan id resolve alice@pyrtan.com`, pinning. pyrtan.com hands out names; nothing yet grants by them. - **Third: hubs.** Signed requests, `--allow` and grants, members following with a device key, revocation refresh, the console's device
- key and pairing. This is the step that replaces handing out the token.
+ key and pairing, with conformance vectors (`tests/conformance/`) so the
+ console's checking agrees with Python's: the console needs SHA3-256 for
+ the checksum, which WebCrypto does not offer, so it carries its own,
+ as it carries SHA-256 today. This is the step that replaces handing out
+ the token.
- **Later:** hub keys in `/actor` and moves; a chat seat that opts in to showing a verified name (chat stays anonymous by default, and still keeps nothing); reaching `.onion` locators, which needs a SOCKS proxy
@@ -388,8 +398,8 @@ hub that checks Alice with a shared secret can also forge Alice.
- **A lost root key is a lost identity.** Device notes expire within a year and nothing can renew them. The person makes a new root, re-pairs their devices, and asks each name provider and each hub operator to
- point at it. The CLI says this when it makes a root, and offers to
- write a backup.
+ point at it. The CLI says this when it makes a root, and names the
+ file to back up.
- **A stolen device** works until it is revoked and the hubs re-read the document: up to an hour, plus however long the new document takes to reach a route the hub asks. `may` bounds what it can do meanwhile. diff --git a/pyproject.toml b/pyproject.toml index 20f6aa5..080a377 100644 --- a/pyproject.toml +++ b/pyproject.toml
@@ -26,6 +26,7 @@ pyrtan = "pyrtan.cli:main"
[project.optional-dependencies] browser = ["playwright>=1.45"]
+identity = ["cryptography>=42"]
[dependency-groups] dev = ["pytest>=8"] diff --git a/src/pyrtan/cli.py b/src/pyrtan/cli.py index b0f4e68..3f36f6f 100644 --- a/src/pyrtan/cli.py +++ b/src/pyrtan/cli.py
@@ -26,6 +26,7 @@
pyrtan pull NAME[@VERSION] [--dir] fetch a tree from a catalog, verified pyrtan index DIR [--out FILE] build a catalog index from a directory 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
``serve`` and ``hub`` take ``--token`` (or ``$PYRTAN_TOKEN``) to require a bearer token, ``--cors ORIGIN`` to let a browser elsewhere call them, and
@@ -48,6 +49,7 @@ from . import __version__
from .catalog import Catalog, CatalogError, build_index, catalog_url from .federation import bootstrap_candidates from .grow import Proposal, propose
+from .identity import ABILITIES, KINDS, IdentityError
from .page import MANIFEST, PAGE, PYRTAN_JS, SERVICE_WORKER from .registry import Registry, default_paths from .runtime import Outcome, Runner
@@ -273,13 +275,40 @@ def main(argv: list[str] | None = None) -> int:
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)")
+ p = sub.add_parser("id", help="your identity: a key, reachable at any domain, named by any domain (_meta/identity.md)")
+ ids = p.add_subparsers(dest="id_command", required=True)
+ q = ids.add_parser("new", help="make your root key and identity document")
+ q.add_argument("--kind", choices=KINDS, default="person", help="what the identity says it is (default person)")
+ q.add_argument("--route", action="append", default=[], metavar="HOST", help="a hub or .onion that takes messages for you (repeatable)")
+ q = ids.add_parser("show", help="your identity, its routes and devices")
+ q.add_argument("--json", action="store_true", help="print the signed identity document")
+ q = ids.add_parser("check", help="check an identity, address or name, or a document (a file, or - for stdin)")
+ q.add_argument("thing", help="pyr:…, pyr:…@host, name@domain, or a JSON file")
+ q = ids.add_parser("device", help="delegate to a device key: a phone, a browser, another machine")
+ q.add_argument("action", choices=["add"])
+ q.add_argument("device", metavar="pyr:…", help="the device's identity")
+ q.add_argument("--may", default="ask,chat", help=f"what the device may ever be granted, comma-separated from {','.join(ABILITIES)} (default ask,chat)")
+ q.add_argument("--label", default="", help="what to call it: phone, laptop")
+ q.add_argument("--days", type=int, default=365, help="how long the note lasts, at most 365 (default 365)")
+ q = ids.add_parser("revoke", help="revoke a device; hubs drop it when they next read your document")
+ q.add_argument("device", metavar="pyr:…")
+ q = ids.add_parser("route", help="add or remove a place you can be reached")
+ q.add_argument("action", choices=["add", "remove"])
+ q.add_argument("host", help="a host[:port] or <56>.onion")
+ q = ids.add_parser("name", help="sign a claim that you are name@domain, for the domain to serve")
+ q.add_argument("name", metavar="NAME@DOMAIN")
+ q.add_argument("--github", type=int, metavar="USER_ID", help="your numeric GitHub user id, for a name at pyrtan.com")
+ q.add_argument("--out", help="write the claim here (default: stdout)")
+ for q in ids.choices.values():
+ q.add_argument("--dir", type=Path, default=None, help="where your key and document live (default $PYRTAN_IDENTITY or ~/.config/pyrtan/identity)")
+
args = parser.parse_args(argv) commands = { "check": cmd_check, "list": cmd_list, "run": cmd_run, "match": cmd_match, "ask": cmd_ask, "chat": cmd_chat, "serve": cmd_serve, "hub": cmd_hub, "gaps": cmd_gaps, "unanswered": cmd_unanswered, "new": cmd_new, "watch": cmd_watch, "watches": cmd_watches, "grow": cmd_grow, "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,
+ "keep": cmd_keep, "kept": cmd_kept, "fetch": cmd_fetch, "id": cmd_id,
} return commands[args.command](args)
@@ -1253,5 +1282,154 @@ def cmd_new(args: argparse.Namespace) -> int:
return 0
+# ---------------------------------------------------------------- pyrtan id
+
+
+def cmd_id(args: argparse.Namespace) -> int:
+ from . import identity
+ directory = args.dir or identity.home()
+ try:
+ return {
+ "new": _id_new, "show": _id_show, "check": _id_check, "device": _id_device,
+ "revoke": _id_revoke, "route": _id_route, "name": _id_name,
+ }[args.id_command](args, directory)
+ except IdentityError as e:
+ print(f"pyrtan id: {e}", file=sys.stderr)
+ return 1
+
+
+def _id_load(directory: Path):
+ """Your root key and your current identity document."""
+ from . import identity
+ key_file, doc_file = directory / "root.key", directory / "identity.json"
+ if not key_file.exists():
+ raise IdentityError(f"no identity in {directory}: make one with `pyrtan id new`")
+ key = identity.Key.load(key_file)
+ try:
+ doc = json.loads(doc_file.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as e:
+ raise IdentityError(f"{doc_file}: {e}") from None
+ problem = identity.check_identity(doc, ident=key.id)
+ if problem:
+ raise IdentityError(f"{doc_file}: {problem}")
+ return key, doc
+
+
+def _id_save(directory: Path, doc: dict[str, Any]) -> None:
+ path = directory / "identity.json"
+ temp = path.with_suffix(".json.tmp")
+ temp.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
+ temp.replace(path)
+
+
+def _id_new(args: argparse.Namespace, directory: Path) -> int:
+ from . import identity
+ key_file = directory / "root.key"
+ if key_file.exists():
+ print(f"pyrtan id: {key_file} exists; an identity is not replaced (move it away first, knowing that it is you)", file=sys.stderr)
+ return 1
+ key = identity.Key.generate()
+ doc = identity.make_identity(key, kind=args.kind, routes=args.route)
+ key.save(key_file)
+ _id_save(directory, doc)
+ print(f"you are {key.id}")
+ print(f"the key is {key_file} (readable only by you). It is the identity: lose it and nobody can give it back,")
+ print("copy it and whoever has the copy is you. Back it up somewhere offline.")
+ print(f"the document hubs and name providers hold is {directory / 'identity.json'}")
+ return 0
+
+
+def _id_show(args: argparse.Namespace, directory: Path) -> int:
+ from . import identity
+ _, doc = _id_load(directory)
+ if args.json:
+ print(json.dumps(doc, indent=2, ensure_ascii=False))
+ return 0
+ print(f"{doc['id']} ({doc['kind']}, updated {time.strftime('%Y-%m-%d %H:%M', time.localtime(doc['updated']))})")
+ print("routes: " + (", ".join(doc["routes"]) if doc["routes"] else "none (add one with `pyrtan id route add HOST`)"))
+ print(f"devices: {len(doc['devices'])}" + ("" if doc["devices"] else " (add one with `pyrtan id device add pyr:…`)"))
+ for note in doc["devices"]:
+ status = identity.device_status(doc, note["device"])
+ until = time.strftime("%Y-%m-%d", time.localtime(note["expires"]))
+ print(f" {note['device']} {note['label'] or '-':10} may {','.join(note['may']) or 'nothing'} {status} until {until}")
+ for device in doc["revoked"]:
+ print(f" {device} revoked")
+ return 0
+
+
+def _id_check(args: argparse.Namespace, directory: Path) -> int:
+ from . import identity
+ thing = args.thing
+ if thing != "-" and not Path(thing).is_file():
+ parsed = identity.parse(thing)
+ if parsed.form == "name":
+ print(f"{parsed}: a name; resolving it at {parsed.domain} comes with the names step (_meta/identity.md §6)")
+ else:
+ print(f"{parsed}: a well-formed {parsed.form}, key {identity.decode(parsed.id).hex()}")
+ return 0
+ try:
+ doc = json.loads(sys.stdin.read() if thing == "-" else Path(thing).read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as e:
+ raise IdentityError(f"{thing}: {e}") from None
+ kind, problem = identity.check_any(doc)
+ if problem:
+ print(f"{thing}: {kind}: refused: {problem}")
+ return 1
+ if kind == "identity":
+ print(f"{thing}: the identity document of {doc['id']}, signed by it; {len(doc['devices'])} device(s), {len(doc['revoked'])} revoked")
+ elif kind == "device":
+ print(f"{thing}: {doc['device']} may {','.join(doc['may']) or 'nothing'} for {doc['root']}, until {time.strftime('%Y-%m-%d', time.localtime(doc['expires']))}")
+ else:
+ print(f"{thing}: {doc['id']} claims {doc['name']}@{doc['domain']}, signed by it")
+ return 0
+
+
+def _id_device(args: argparse.Namespace, directory: Path) -> int:
+ from . import identity
+ if not 0 < args.days <= 365:
+ raise IdentityError("--days is from 1 to 365")
+ key, doc = _id_load(directory)
+ may = [a for a in args.may.replace(" ", "").split(",") if a]
+ issued = int(time.time())
+ note = identity.make_device(key, args.device, may, label=args.label, issued=issued, expires=issued + args.days * 86400)
+ _id_save(directory, identity.with_device(doc, key, note))
+ print(json.dumps(note, ensure_ascii=False))
+ print(f"{note['device']} may {','.join(note['may']) or 'nothing'} for you until {time.strftime('%Y-%m-%d', time.localtime(note['expires']))}; give the device the note above", file=sys.stderr)
+ return 0
+
+
+def _id_revoke(args: argparse.Namespace, directory: Path) -> int:
+ from . import identity
+ key, doc = _id_load(directory)
+ device = identity.normal(args.device)
+ _id_save(directory, identity.with_revoked(doc, key, device))
+ print(f"revoked {device}; hubs drop it when they next read your document")
+ return 0
+
+
+def _id_route(args: argparse.Namespace, directory: Path) -> int:
+ from . import identity
+ key, doc = _id_load(directory)
+ host = identity.check_locator(args.host)
+ routes = [r for r in doc["routes"] if r != host] + ([host] if args.action == "add" else [])
+ _id_save(directory, identity.with_routes(doc, key, routes))
+ print("routes: " + (", ".join(routes) or "none"))
+ return 0
+
+
+def _id_name(args: argparse.Namespace, directory: Path) -> int:
+ from . import identity
+ key, _ = _id_load(directory)
+ extra = {"github": args.github} if args.github is not None else {}
+ claim = identity.make_name(key, args.name, **extra)
+ text = json.dumps(claim, indent=2, ensure_ascii=False) + "\n"
+ if args.out:
+ Path(args.out).write_text(text, encoding="utf-8")
+ print(f"wrote {args.out}: {claim['id']} claims {claim['name']}@{claim['domain']}; the domain serves it at /.well-known/pyrtan/name/{claim['name']}.json")
+ else:
+ sys.stdout.write(text)
+ return 0
+
+
if __name__ == "__main__": sys.exit(main()) diff --git a/src/pyrtan/identity.py b/src/pyrtan/identity.py new file mode 100644 index 0000000..671a4ce --- /dev/null +++ b/src/pyrtan/identity.py
@@ -0,0 +1,522 @@
+"""Identity: a person, a hub or a device as an Ed25519 key.
+
+An identity is its public key, written ``pyr:`` and 56 characters: the
+lowercase base32 of the 32-byte key, a 2-byte SHA3-256 checksum and a
+version byte, the layout of a Tor v3 onion address with Pyrtan's own
+checksum string. ``pyr:<key>@host`` says where an identity can be
+reached; ``alice@example.org`` is a name a domain vouches for. Encoding,
+decoding and parsing those need only the standard library.
+
+Three kinds of signed document stand on their own, so that anyone can
+hold a copy and nobody has to trust the holder: the *identity document*
+(routes, devices, revocations), the *device note* (a root delegating to
+a device key, with what it may do and until when), and the *name claim*
+(an identity saying it is ``name@domain``). Each is signed over
+``pyrtan-doc-v1\\n`` and the document's canonical JSON without ``sig``.
+
+Signing and verifying need ``cryptography`` (``pip install
+'pyrtan[identity]'``), imported only when a signature is made or
+checked. This module refuses to do Ed25519 itself: a private key is
+handled only by a maintained library. It also refuses floats in a
+document, so that every runtime produces the same bytes. The design is
+``_meta/identity.md``.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import ipaddress
+import json
+import os
+import re
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from .store import canonical
+
+PREFIX = "pyr:"
+VERSION_ED25519 = 1
+VERSIONS = {VERSION_ED25519: 32}
+CHECKSUM_TAG = b".pyrtan id"
+DOC_TAG = b"pyrtan-doc-v1\n"
+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
+ABILITIES = ("ask", "chat", "run", "operate")
+KINDS = ("person", "hub")
+MAX_ROUTES = 16
+MAX_DEVICES = 64
+
+LOCAL = re.compile(r"^[a-z0-9][a-z0-9._-]{0,62}$")
+LABEL = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
+INSTALL = "signatures need the identity extra: pip install 'pyrtan[identity]'"
+
+
+class IdentityError(ValueError):
+ pass
+
+
+# ---------------------------------------------------------------- identifiers
+
+
+def _checksum(key: bytes, version: int) -> bytes:
+ return hashlib.sha3_256(CHECKSUM_TAG + key + bytes([version])).digest()[:2]
+
+
+def encode(key: bytes, version: int = VERSION_ED25519) -> str:
+ """The identifier of a public key."""
+ if VERSIONS.get(version) != len(key):
+ raise IdentityError(f"a version {version} key is {VERSIONS.get(version, '?')} bytes, not {len(key)}")
+ raw = key + _checksum(key, version) + bytes([version])
+ return PREFIX + base64.b32encode(raw).decode("ascii").lower().rstrip("=")
+
+
+def decode(text: Any) -> bytes:
+ """The public key an identifier names; IdentityError says what is wrong."""
+ if not isinstance(text, str) or not text.lower().startswith(PREFIX):
+ raise IdentityError(f"{text!r} is not an identity: it starts {PREFIX}")
+ body = text[len(PREFIX):].lower()
+ if len(body) != ID_LENGTH:
+ raise IdentityError(f"{text!r} is not an identity: {len(body)} characters after {PREFIX}, not {ID_LENGTH}")
+ try:
+ raw = base64.b32decode(body.upper() + "=" * (-len(body) % 8))
+ except ValueError:
+ raise IdentityError(f"{text!r} is not an identity: not base32") from None
+ key, checksum, version = raw[:-3], raw[-3:-1], raw[-1]
+ if version not in VERSIONS:
+ raise IdentityError(f"{text!r} is version {version}, which this pyrtan does not know")
+ if _checksum(key, version) != checksum:
+ raise IdentityError(f"{text!r} is not an identity: the checksum does not match (a typo?)")
+ return key
+
+
+def normal(text: Any) -> str:
+ """The identifier as it is compared: checked, lowercase."""
+ decode(text)
+ return text.lower()
+
+
+def short(text: str) -> str:
+ """For display only; never compared."""
+ return f"{PREFIX}{text[len(PREFIX):len(PREFIX) + 4]}…{text[-4:]}"
+
+
+def check_locator(text: Any) -> str:
+ """``host``, ``host:port``, ``<56>.onion`` or ``[ip]:port``, lowercased."""
+ if not isinstance(text, str) or not text:
+ raise IdentityError(f"{text!r} is not a locator")
+ loc = text.lower()
+ host, port = loc, None
+ if loc.startswith("["):
+ end = loc.find("]")
+ if end < 0:
+ raise IdentityError(f"{text!r} is not a locator: an unclosed [")
+ host, rest = loc[1:end], loc[end + 1:]
+ try:
+ ipaddress.IPv6Address(host)
+ except ValueError:
+ raise IdentityError(f"{text!r} is not a locator: {host!r} is not an IPv6 address") from None
+ if rest:
+ if not rest.startswith(":"):
+ raise IdentityError(f"{text!r} is not a locator")
+ port = rest[1:]
+ else:
+ if ":" in loc:
+ host, port = loc.rsplit(":", 1)
+ check_host(host, text)
+ if port is not None and (not port.isdigit() or not 0 < int(port) < 65536):
+ raise IdentityError(f"{text!r} is not a locator: {port!r} is not a port")
+ return loc
+
+
+def check_host(host: str, given: Any = None) -> str:
+ given = host if given is None else given
+ labels = host.split(".")
+ if len(host) > 253 or not all(LABEL.match(label) for label in labels):
+ raise IdentityError(f"{given!r} is not a locator: {host!r} is not a host name")
+ if labels[-1] == "onion" and (len(labels) < 2 or len(labels[-2]) != 56):
+ raise IdentityError(f"{given!r} is not a locator: an onion address is 56 characters before .onion")
+ return host
+
+
+@dataclass(frozen=True)
+class Parsed:
+ form: str # "identity", "address" or "name"
+ id: str | None = None
+ locator: str | None = None
+ name: str | None = None
+ domain: str | None = None
+
+ def __str__(self) -> str:
+ if self.form == "name":
+ return f"{self.name}@{self.domain}"
+ return self.id + (f"@{self.locator}" if self.locator else "")
+
+
+def parse(text: Any) -> Parsed:
+ """Any of the three forms: ``pyr:<key>``, ``pyr:<key>@locator``, ``name@domain``."""
+ if not isinstance(text, str) or not text.strip():
+ raise IdentityError(f"{text!r} is not an identity, an address or a name")
+ text = text.strip()
+ if text.lower().startswith(PREFIX):
+ ident, _, loc = text.partition("@")
+ ident = normal(ident)
+ if not _:
+ return Parsed("identity", id=ident)
+ return Parsed("address", id=ident, locator=check_locator(loc))
+ local, at, domain = text.lower().rpartition("@")
+ if not at:
+ raise IdentityError(f"{text!r} is not an identity ({PREFIX}…) or a name (name@domain)")
+ if not LOCAL.match(local):
+ raise IdentityError(f"{text!r} is not a name: {local!r} is not lowercase letters, digits, dot, dash, underscore, at most 63")
+ return Parsed("name", name=local, domain=check_host(domain, text))
+
+
+# ---------------------------------------------------------------- signatures
+
+
+def _ed25519():
+ try:
+ from cryptography.hazmat.primitives.asymmetric import ed25519
+ except ImportError:
+ raise IdentityError(INSTALL) from None
+ return ed25519
+
+
+def _b64(raw: bytes) -> str:
+ return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
+
+
+def _unb64(text: Any) -> bytes:
+ if not isinstance(text, str) or not re.fullmatch(r"[A-Za-z0-9_-]*", text):
+ raise IdentityError("not base64url")
+ return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
+
+
+class Key:
+ """A private key, held in memory; ``id`` is its identity."""
+
+ def __init__(self, seed: bytes):
+ if len(seed) != 32:
+ raise IdentityError("an Ed25519 private key is 32 bytes")
+ self._private = _ed25519().Ed25519PrivateKey.from_private_bytes(seed)
+ self._seed = seed
+ from cryptography.hazmat.primitives import serialization as s
+ self.id = encode(self._private.public_key().public_bytes(s.Encoding.Raw, s.PublicFormat.Raw))
+
+ @classmethod
+ def generate(cls) -> "Key":
+ return cls(os.urandom(32))
+
+ def sign(self, message: bytes) -> bytes:
+ return self._private.sign(message)
+
+ def __repr__(self) -> str:
+ return f"Key({short(self.id)})"
+
+ # A key file is JSON, written once, readable only by its owner.
+
+ def save(self, path: Path, *, kind: str = "root") -> Path:
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+ data = json.dumps({"pyrtan-key": 1, "kind": kind, "id": self.id, "seed": _b64(self._seed)}, indent=2) + "\n"
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
+ f.write(data)
+ return path
+
+ @classmethod
+ def load(cls, path: Path) -> "Key":
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as e:
+ raise IdentityError(f"{path}: {e}") from None
+ if not isinstance(data, dict) or data.get("pyrtan-key") != 1:
+ raise IdentityError(f"{path} is not a pyrtan key file")
+ key = cls(_unb64(data.get("seed")))
+ if data.get("id") != key.id:
+ raise IdentityError(f"{path}: the id in the file is not the key's")
+ return key
+
+
+def verify(ident: str, message: bytes, signature: bytes) -> bool:
+ """Whether ``signature`` is ``ident``'s over ``message``."""
+ public = _ed25519().Ed25519PublicKey.from_public_bytes(decode(ident))
+ try:
+ public.verify(signature, message)
+ except Exception:
+ return False
+ return True
+
+
+# ---------------------------------------------------------------- documents
+
+
+def _plain(value: Any, where: str = "document") -> None:
+ if isinstance(value, float):
+ raise IdentityError(f"{where} holds a number with a fraction; documents hold strings, integers, lists and objects")
+ if isinstance(value, dict):
+ for k, v in value.items():
+ if not isinstance(k, str):
+ raise IdentityError(f"{where} has a key that is not a string")
+ _plain(v, f"{where}.{k}")
+ elif isinstance(value, list):
+ for i, v in enumerate(value):
+ _plain(v, f"{where}[{i}]")
+ elif not (value is None or isinstance(value, (str, int, bool))):
+ raise IdentityError(f"{where} holds a {type(value).__name__}")
+
+
+def signed_bytes(doc: dict[str, Any]) -> bytes:
+ """What a document's ``sig`` is over."""
+ body = {k: v for k, v in doc.items() if k != "sig"}
+ _plain(body)
+ return DOC_TAG + canonical(body)
+
+
+def sign_document(doc: dict[str, Any], key: Key) -> dict[str, Any]:
+ body = {k: v for k, v in doc.items() if k != "sig"}
+ return {**body, "sig": _b64(key.sign(signed_bytes(body)))}
+
+
+def check_signature(doc: Any, by: str) -> str | None:
+ """None if ``doc`` is signed by ``by``; otherwise what is wrong."""
+ if not isinstance(doc, dict) or "sig" not in doc:
+ return "not signed"
+ try:
+ signature = _unb64(doc["sig"])
+ message = signed_bytes(doc)
+ except IdentityError as e:
+ return str(e)
+ except ValueError:
+ return "the signature is not base64url"
+ return None if verify(by, message, signature) else f"the signature is not {short(by)}'s"
+
+
+def _int(doc: dict[str, Any], field: str) -> int:
+ value = doc.get(field)
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
+ raise IdentityError(f"{field} is not a time (whole seconds since 1970)")
+ return value
+
+
+def _abilities(value: Any) -> list[str]:
+ if not isinstance(value, list) or not all(a in ABILITIES for a in value) or len(set(value)) != len(value):
+ raise IdentityError(f"may is a list of {', '.join(ABILITIES)}, each once")
+ return value
+
+
+# Device notes.
+
+
+def make_device(root: Key, device: str, may: list[str], *, label: str = "", issued: int | None = None, expires: int | None = None) -> dict[str, Any]:
+ issued = int(time.time()) if issued is None else issued
+ note = {
+ "pyrtan-device": 1, "root": root.id, "device": normal(device), "label": label,
+ "may": _abilities(may), "issued": issued, "expires": issued + YEAR if expires is None else expires,
+ }
+ if note["device"] == root.id:
+ raise IdentityError("a root does not delegate to itself; it signs for itself")
+ _check_device_fields(note)
+ return sign_document(note, root)
+
+
+def _check_device_fields(note: dict[str, Any]) -> None:
+ if note.get("pyrtan-device") != 1:
+ raise IdentityError("not a device note (pyrtan-device: 1)")
+ normal(note.get("root"))
+ normal(note.get("device"))
+ if not isinstance(note.get("label", ""), str) or len(note.get("label", "")) > 64:
+ raise IdentityError("label is a string of at most 64 characters")
+ _abilities(note.get("may"))
+ issued, expires = _int(note, "issued"), _int(note, "expires")
+ if not issued < expires <= issued + YEAR:
+ raise IdentityError("a device note expires after it is issued, and within a year")
+
+
+def check_device(note: Any, *, root: str | None = None, now: float | None = None) -> str | None:
+ """None if the note holds (signed by its root, and by ``root`` if given, and current); otherwise why not."""
+ if not isinstance(note, dict):
+ return "not a device note"
+ try:
+ _check_device_fields(note)
+ except IdentityError as e:
+ return str(e)
+ if root is not None and note["root"].lower() != root.lower():
+ return f"the note is {short(note['root'])}'s, not {short(root)}'s"
+ problem = check_signature(note, note["root"])
+ if problem:
+ return problem
+ now = time.time() if now is None else now
+ if note["issued"] > now + WINDOW:
+ return "the note is issued in the future"
+ if note["expires"] <= now:
+ return "the note has expired"
+ return None
+
+
+# Identity documents.
+
+
+def make_identity(root: Key, *, kind: str = "person", routes: list[str] | None = None, devices: list[dict[str, Any]] | None = None,
+ revoked: list[str] | None = None, updated: int | None = None) -> dict[str, Any]:
+ doc = {
+ "pyrtan-identity": 1, "id": root.id, "kind": kind,
+ "routes": [check_locator(r) for r in routes or []],
+ "devices": list(devices or []), "revoked": [normal(r) for r in revoked or []],
+ "updated": int(time.time()) if updated is None else updated,
+ }
+ _check_identity_fields(doc)
+ return sign_document(doc, root)
+
+
+def _check_identity_fields(doc: dict[str, Any]) -> None:
+ if doc.get("pyrtan-identity") != 1:
+ raise IdentityError("not an identity document (pyrtan-identity: 1)")
+ ident = normal(doc.get("id"))
+ if doc.get("kind") not in KINDS:
+ raise IdentityError(f"kind is one of {', '.join(KINDS)}")
+ routes = doc.get("routes")
+ if not isinstance(routes, list) or len(routes) > MAX_ROUTES:
+ raise IdentityError(f"routes is a list of at most {MAX_ROUTES} locators")
+ for r in routes:
+ if check_locator(r) != r:
+ raise IdentityError(f"route {r!r} is not lowercase")
+ revoked = doc.get("revoked")
+ if not isinstance(revoked, list) or any(normal(r) != r for r in revoked):
+ raise IdentityError("revoked is a list of lowercase identities")
+ devices = doc.get("devices")
+ if not isinstance(devices, list) or len(devices) > MAX_DEVICES:
+ raise IdentityError(f"devices is a list of at most {MAX_DEVICES} device notes")
+ seen = set()
+ for note in devices:
+ if not isinstance(note, dict):
+ raise IdentityError("devices holds something that is not a device note")
+ _check_device_fields(note)
+ if note["root"] != ident:
+ raise IdentityError(f"device {short(note['device'])} is delegated by {short(note['root'])}, not this identity")
+ if note["device"] in seen:
+ raise IdentityError(f"device {short(note['device'])} is listed twice")
+ seen.add(note["device"])
+ _int(doc, "updated")
+
+
+def check_identity(doc: Any, *, ident: str | None = None, now: float | None = None) -> str | None:
+ """None if the document holds (well formed, signed by its own id, every note signed by it, not from the future)."""
+ if not isinstance(doc, dict):
+ return "not an identity document"
+ try:
+ _check_identity_fields(doc)
+ except IdentityError as e:
+ return str(e)
+ if ident is not None and doc["id"] != ident.lower():
+ return f"the document is {short(doc['id'])}'s, not {short(ident)}'s"
+ problem = check_signature(doc, doc["id"])
+ if problem:
+ return problem
+ for note in doc["devices"]:
+ problem = check_signature(note, doc["id"])
+ if problem:
+ return f"device {short(note['device'])}: {problem}"
+ if doc["updated"] > (time.time() if now is None else now) + WINDOW:
+ return "the document is from the future"
+ return None
+
+
+def newest(docs: list[Any], *, ident: str | None = None, now: float | None = None) -> dict[str, Any] | None:
+ """Of several copies, the one with the highest ``updated`` whose checks hold."""
+ good = [d for d in docs if check_identity(d, ident=ident, now=now) is None]
+ return max(good, key=lambda d: d["updated"], default=None)
+
+
+def device_status(doc: dict[str, Any], device: str, *, now: float | None = None) -> str:
+ """``ok``, ``expired``, ``revoked`` or ``unknown``, for a device under a checked identity document."""
+ device = device.lower()
+ if device in doc["revoked"]:
+ return "revoked"
+ for note in doc["devices"]:
+ if note["device"] == device:
+ return "ok" if note["expires"] > (time.time() if now is None else now) else "expired"
+ return "unknown"
+
+
+def with_device(doc: dict[str, Any], root: Key, note: dict[str, Any]) -> dict[str, Any]:
+ """A new document, signed, with ``note`` in place of any earlier note for the same device."""
+ devices = [n for n in doc["devices"] if n["device"] != note["device"]] + [note]
+ revoked = [r for r in doc["revoked"] if r != note["device"]]
+ return _reissue(doc, root, devices=devices, revoked=revoked)
+
+
+def with_revoked(doc: dict[str, Any], root: Key, device: str) -> dict[str, Any]:
+ device = normal(device)
+ devices = [n for n in doc["devices"] if n["device"] != device]
+ revoked = doc["revoked"] + ([device] if device not in doc["revoked"] else [])
+ return _reissue(doc, root, devices=devices, revoked=revoked)
+
+
+def with_routes(doc: dict[str, Any], root: Key, routes: list[str]) -> dict[str, Any]:
+ return _reissue(doc, root, routes=routes)
+
+
+def _reissue(doc: dict[str, Any], root: Key, **changes: Any) -> dict[str, Any]:
+ if doc["id"] != root.id:
+ raise IdentityError(f"the document is {short(doc['id'])}'s; this key is {short(root.id)}")
+ fields = {k: doc[k] for k in ("kind", "routes", "devices", "revoked")} | changes
+ # Strictly newer than the copy it replaces, or a holder would keep the old one.
+ return make_identity(root, updated=max(int(time.time()), doc["updated"] + 1), **fields)
+
+
+# Name claims.
+
+
+def make_name(key: Key, name: str, *, issued: int | None = None, **extra: Any) -> dict[str, Any]:
+ """``key`` claiming ``name`` (``local@domain``). ``extra`` is a domain's own fields (pyrtan.com's ``github``)."""
+ parsed = parse(name)
+ if parsed.form != "name":
+ raise IdentityError(f"{name!r} is not a name (name@domain)")
+ claim = {"pyrtan-name": 1, "name": parsed.name, "domain": parsed.domain, "id": key.id,
+ "issued": int(time.time()) if issued is None else issued, **extra}
+ _plain(claim)
+ return sign_document(claim, key)
+
+
+def check_name(claim: Any, *, name: str | None = None) -> str | None:
+ """None if the claim holds, and is for ``name`` if given; otherwise why not."""
+ if not isinstance(claim, dict) or claim.get("pyrtan-name") != 1:
+ return "not a name claim (pyrtan-name: 1)"
+ try:
+ normal(claim.get("id"))
+ _int(claim, "issued")
+ stated = parse(f"{claim.get('name')}@{claim.get('domain')}")
+ except IdentityError as e:
+ return str(e)
+ if stated.form != "name" or (stated.name, stated.domain) != (claim["name"], claim["domain"]):
+ return "name and domain are not a lowercase name@domain"
+ if name is not None and str(parse(name)) != str(stated):
+ return f"the claim is for {stated}, not {name}"
+ return check_signature(claim, claim["id"])
+
+
+def check_any(doc: Any, *, now: float | None = None) -> tuple[str, str | None]:
+ """Which kind of document this is, and None or what is wrong with it."""
+ if isinstance(doc, dict):
+ if "pyrtan-identity" in doc:
+ return "identity", check_identity(doc, now=now)
+ if "pyrtan-device" in doc:
+ return "device", check_device(doc, now=now)
+ if "pyrtan-name" in doc:
+ return "name", check_name(doc)
+ return "unknown", "not a pyrtan identity document, device note or name claim"
+
+
+# ---------------------------------------------------------------- where the CLI keeps them
+
+
+def home() -> Path:
+ """``$PYRTAN_IDENTITY``, else ``$XDG_CONFIG_HOME/pyrtan/identity``, else ``~/.config/pyrtan/identity``."""
+ if os.environ.get("PYRTAN_IDENTITY"):
+ return Path(os.environ["PYRTAN_IDENTITY"])
+ base = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
+ return base / "pyrtan" / "identity"
diff --git a/tests/test_identity.py b/tests/test_identity.py new file mode 100644 index 0000000..541c1b6 --- /dev/null +++ b/tests/test_identity.py
@@ -0,0 +1,318 @@
+import importlib.util
+import json
+import os
+import stat
+import sys
+import time
+
+import pytest
+
+from pyrtan import identity
+from pyrtan.cli import main
+from pyrtan.identity import IdentityError
+
+needs_crypto = pytest.mark.skipif(importlib.util.find_spec("cryptography") is None, reason="needs pyrtan[identity]")
+
+# RFC 8032 §7.1, tests 1 and 2: secret key, public key, message, signature.
+RFC8032 = [
+ ("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60",
+ "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a", "",
+ "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"),
+ ("4ccd089b28ff96da9db6c346ec114e0f5b8a319f35aba624da8cf6ed4fb8a6fb",
+ "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c", "72",
+ "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"),
+]
+# The vector in _meta/identity.md §2: RFC 8032 test 1's public key as an identifier.
+VECTOR = "pyr:25njqamcweflpvkl73j4szahhihoc4xt3ktcgjnpaingr5yhkenaooab"
+ONION = "a" * 56 + ".onion"
+NOW = 1_800_000_000
+
+
+# ---------------------------------------------------------------- identifiers (standard library only)
+
+
+def test_the_design_notes_vector():
+ key = bytes.fromhex(RFC8032[0][1])
+ assert identity.encode(key) == VECTOR
+ assert identity.decode(VECTOR) == key
+ assert len(VECTOR) == len("pyr:") + 56
+
+
+def test_decode_takes_any_case_and_compares_lowercase():
+ assert identity.decode(VECTOR.upper()) == bytes.fromhex(RFC8032[0][1])
+ assert identity.normal(VECTOR.upper()) == VECTOR
+
+
+@pytest.mark.parametrize("text, reason", [
+ (VECTOR[:20] + ("b" if VECTOR[20] != "b" else "c") + VECTOR[21:], "checksum"),
+ (VECTOR[:-1], "characters"),
+ (VECTOR + "a", "characters"),
+ ("pyx:" + VECTOR[4:], "starts pyr:"),
+ (VECTOR[:10] + "1" + VECTOR[11:], "base32"),
+ (VECTOR[:-2] + "aa", "version"),
+ (None, "starts pyr:"),
+])
+def test_decode_refuses(text, reason):
+ with pytest.raises(IdentityError, match=reason):
+ identity.decode(text)
+
+
+def test_encode_refuses_a_key_of_the_wrong_length():
+ with pytest.raises(IdentityError):
+ identity.encode(b"\x00" * 31)
+
+
+def test_short_is_for_display():
+ assert identity.short(VECTOR) == "pyr:25nj…ooab"
+
+
+def test_parse_the_three_forms():
+ assert identity.parse(VECTOR) == identity.Parsed("identity", id=VECTOR)
+ a = identity.parse(f"{VECTOR.upper()}@Hub.Example:8443")
+ assert (a.form, a.id, a.locator) == ("address", VECTOR, "hub.example:8443")
+ assert str(a) == f"{VECTOR}@hub.example:8443"
+ assert identity.parse(f"{VECTOR}@{ONION}").locator == ONION
+ assert identity.parse(f"{VECTOR}@[::1]:8700").locator == "[::1]:8700"
+ n = identity.parse("Alice@PyrTan.com")
+ assert (n.form, n.name, n.domain, str(n)) == ("name", "alice", "pyrtan.com", "alice@pyrtan.com")
+ assert identity.parse(f"alice@{ONION}").domain == ONION
+
+
+@pytest.mark.parametrize("text", [
+ "alice", "@pyrtan.com", "-alice@pyrtan.com", "al ice@pyrtan.com", "alice@pyrtan..com", "alice@-pyrtan.com",
+ "alice@xyz.onion", f"{VECTOR}@", f"{VECTOR}@host:0", f"{VECTOR}@host:70000", f"{VECTOR}@[nope]:1", f"{VECTOR}@[::1",
+ "a" * 64 + "@pyrtan.com", "",
+])
+def test_parse_refuses(text):
+ with pytest.raises(IdentityError):
+ identity.parse(text)
+
+
+def test_without_the_extra_signing_says_how_to_install(monkeypatch):
+ monkeypatch.setitem(sys.modules, "cryptography.hazmat.primitives.asymmetric", None)
+ with pytest.raises(IdentityError, match=r"pyrtan\[identity\]"):
+ identity.Key.generate()
+ with pytest.raises(IdentityError, match=r"pyrtan\[identity\]"):
+ identity.verify(VECTOR, b"", b"")
+ assert identity.decode(VECTOR) # identifiers still work
+
+
+# ---------------------------------------------------------------- signatures
+
+
+@needs_crypto
+@pytest.mark.parametrize("secret, public, message, signature", RFC8032)
+def test_rfc8032_vectors(secret, public, message, signature):
+ key = identity.Key(bytes.fromhex(secret))
+ assert key.id == identity.encode(bytes.fromhex(public))
+ assert key.sign(bytes.fromhex(message)).hex() == signature
+ assert identity.verify(key.id, bytes.fromhex(message), bytes.fromhex(signature))
+ assert not identity.verify(key.id, bytes.fromhex(message) + b"x", bytes.fromhex(signature))
+ assert not identity.verify(key.id, bytes.fromhex(message), b"short")
+
+
+@needs_crypto
+def test_key_files_are_private_and_round_trip(tmp_path):
+ key = identity.Key.generate()
+ path = key.save(tmp_path / "id" / "root.key")
+ assert stat.S_IMODE(os.stat(path).st_mode) == 0o600
+ assert stat.S_IMODE(os.stat(path.parent).st_mode) == 0o700
+ assert identity.Key.load(path).id == key.id
+ with pytest.raises(FileExistsError):
+ key.save(path)
+ data = json.loads(path.read_text())
+ data["id"] = identity.Key.generate().id
+ path.write_text(json.dumps(data))
+ with pytest.raises(IdentityError, match="not the key's"):
+ identity.Key.load(path)
+
+
+# ---------------------------------------------------------------- documents
+
+
+@pytest.fixture
+def root():
+ pytest.importorskip("cryptography")
+ return identity.Key(bytes.fromhex(RFC8032[0][0]))
+
+
+@pytest.fixture
+def device():
+ pytest.importorskip("cryptography")
+ return identity.Key(bytes.fromhex(RFC8032[1][0]))
+
+
+def test_device_notes(root, device):
+ note = identity.make_device(root, device.id, ["chat", "run"], label="phone", issued=NOW)
+ assert note["expires"] == NOW + identity.YEAR
+ assert identity.check_device(note, now=NOW + 10) is None
+ assert identity.check_device(note, root=root.id, now=NOW) is None
+ assert "not" in identity.check_device(note, root=device.id, now=NOW)
+ assert identity.check_device(note, now=NOW + identity.YEAR) == "the note has expired"
+ assert identity.check_device(note, now=NOW - 3600) == "the note is issued in the future"
+ assert "signature" in identity.check_device({**note, "may": ["chat", "run", "operate"]}, now=NOW)
+ assert "signature" in identity.check_device({**note, "expires": NOW + 100}, now=NOW)
+
+
+def test_device_notes_refuse(root, device):
+ with pytest.raises(IdentityError, match="within a year"):
+ identity.make_device(root, device.id, ["chat"], issued=NOW, expires=NOW + identity.YEAR + 1)
+ with pytest.raises(IdentityError, match="may"):
+ identity.make_device(root, device.id, ["fly"])
+ with pytest.raises(IdentityError, match="each once"):
+ identity.make_device(root, device.id, ["chat", "chat"])
+ with pytest.raises(IdentityError, match="itself"):
+ identity.make_device(root, root.id, ["chat"])
+
+
+def test_identity_documents(root, device):
+ doc = identity.make_identity(root, routes=["Hub.Example", ONION], updated=NOW)
+ assert doc["routes"] == ["hub.example", ONION]
+ assert identity.check_identity(doc, now=NOW) is None
+ assert identity.check_identity(doc, ident=root.id.upper(), now=NOW) is None
+ assert "not" in identity.check_identity(doc, ident=device.id, now=NOW)
+ assert "signature" in identity.check_identity({**doc, "routes": ["evil.example"]}, now=NOW)
+ assert identity.check_identity(doc, now=NOW - 3600) == "the document is from the future"
+ assert "kind" in identity.check_identity({**doc, "kind": "god"}, now=NOW)
+
+
+def test_devices_and_revocation_reissue_newer_documents(root, device):
+ doc = identity.make_identity(root, updated=int(time.time()) - 1000)
+ note = identity.make_device(root, device.id, ["chat"], label="phone")
+ with_it = identity.with_device(doc, root, note)
+ assert with_it["updated"] > doc["updated"]
+ assert identity.check_identity(with_it) is None
+ assert identity.device_status(with_it, device.id.upper()) == "ok"
+ renewed = identity.with_device(with_it, root, identity.make_device(root, device.id, ["chat", "run"]))
+ assert len(renewed["devices"]) == 1 and renewed["devices"][0]["may"] == ["chat", "run"]
+ revoked = identity.with_revoked(renewed, root, device.id)
+ assert revoked["updated"] > renewed["updated"]
+ assert identity.device_status(revoked, device.id) == "revoked"
+ assert revoked["devices"] == [] and revoked["revoked"] == [device.id]
+ assert identity.device_status(revoked, root.id) == "unknown"
+ assert identity.device_status(with_it, device.id, now=note["expires"]) == "expired"
+ assert identity.newest([doc, revoked, with_it]) == revoked
+ with pytest.raises(IdentityError, match="this key"):
+ identity.with_revoked(revoked, device, device.id)
+
+
+def test_newest_ignores_a_forged_or_foreign_copy(root, device):
+ doc = identity.make_identity(root, updated=NOW)
+ forged = {**doc, "routes": ["evil.example"], "updated": NOW + 10}
+ foreign = identity.make_identity(device, updated=NOW + 20)
+ assert identity.newest([doc, forged, foreign], ident=root.id, now=NOW) == doc
+ assert identity.newest([forged], now=NOW) is None
+
+
+def test_a_document_cannot_carry_another_roots_device(root, device):
+ other = identity.Key.generate()
+ note = identity.make_device(other, device.id, ["chat"])
+ with pytest.raises(IdentityError, match="delegated by"):
+ identity.make_identity(root, devices=[note])
+
+
+def test_documents_hold_no_floats(root):
+ with pytest.raises(IdentityError, match="fraction"):
+ identity.sign_document({"pyrtan-name": 1, "x": 1.5}, root)
+ assert "fraction" in identity.check_signature({"x": 1.5, "sig": "AA"}, root.id)
+
+
+def test_name_claims(root, device):
+ claim = identity.make_name(root, "Alice@PyrTan.com", issued=NOW, github=123)
+ assert (claim["name"], claim["domain"], claim["github"]) == ("alice", "pyrtan.com", 123)
+ assert identity.check_name(claim) is None
+ assert identity.check_name(claim, name="ALICE@pyrtan.com") is None
+ assert identity.check_name(claim, name="bob@pyrtan.com") == "the claim is for alice@pyrtan.com, not bob@pyrtan.com"
+ assert "signature" in identity.check_name({**claim, "github": 456})
+ assert "signature" in identity.check_name({**claim, "id": device.id})
+ assert "lowercase" in identity.check_name({**claim, "name": "Alice"})
+ with pytest.raises(IdentityError):
+ identity.make_name(root, VECTOR)
+
+
+def test_check_any(root, device):
+ assert identity.check_any(identity.make_identity(root)) == ("identity", None)
+ assert identity.check_any(identity.make_device(root, device.id, ["ask"])) == ("device", None)
+ assert identity.check_any(identity.make_name(root, "a@b.example")) == ("name", None)
+ assert identity.check_any([])[0] == "unknown"
+
+
+def test_home(monkeypatch, tmp_path):
+ monkeypatch.setenv("PYRTAN_IDENTITY", str(tmp_path / "x"))
+ assert identity.home() == tmp_path / "x"
+ monkeypatch.delenv("PYRTAN_IDENTITY")
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
+ assert identity.home() == tmp_path / "pyrtan" / "identity"
+
+
+# ---------------------------------------------------------------- pyrtan id
+
+
+def run(capsys, *argv):
+ code = main(list(argv))
+ out = capsys.readouterr()
+ return code, out.out, out.err
+
+
+@needs_crypto
+def test_cli_new_device_revoke_route(tmp_path, capsys):
+ d = str(tmp_path / "me")
+ code, out, _ = run(capsys, "id", "new", "--route", "hub.example", "--dir", d)
+ assert code == 0 and "you are pyr:" in out and "Back it up" in out
+ me = out.split()[2]
+ assert stat.S_IMODE(os.stat(tmp_path / "me" / "root.key").st_mode) == 0o600
+ code, _, err = run(capsys, "id", "new", "--dir", d)
+ assert code == 1 and "exists" in err
+
+ phone = identity.Key.generate().id
+ code, out, err = run(capsys, "id", "device", "add", phone, "--may", "chat,run", "--label", "phone", "--days", "30", "--dir", d)
+ note = json.loads(out)
+ assert code == 0 and note["root"] == me and note["may"] == ["chat", "run"] and identity.check_device(note) is None
+ assert note["expires"] - note["issued"] == 30 * 86400
+ code, out, _ = run(capsys, "id", "show", "--dir", d)
+ assert me in out and "hub.example" in out and phone in out and "ok until" in out
+
+ code, _, _ = run(capsys, "id", "route", "add", ONION, "--dir", d)
+ code, _, _ = run(capsys, "id", "route", "remove", "hub.example", "--dir", d)
+ code, out, _ = run(capsys, "id", "revoke", phone, "--dir", d)
+ assert code == 0 and "revoked" in out
+ code, out, _ = run(capsys, "id", "show", "--json", "--dir", d)
+ doc = json.loads(out)
+ assert identity.check_identity(doc, ident=me) is None
+ assert doc["routes"] == [ONION] and doc["devices"] == [] and doc["revoked"] == [phone]
+
+ code, _, err = run(capsys, "id", "device", "add", phone, "--may", "fly", "--dir", d)
+ assert code == 1 and "may" in err
+ code, _, err = run(capsys, "id", "device", "add", phone, "--days", "400", "--dir", d)
+ assert code == 1 and "365" in err
+
+
+@needs_crypto
+def test_cli_name_and_check(tmp_path, capsys):
+ d = str(tmp_path / "me")
+ run(capsys, "id", "new", "--dir", d)
+ out_file = tmp_path / "alice.json"
+ code, out, _ = run(capsys, "id", "name", "alice@pyrtan.com", "--github", "123", "--out", str(out_file), "--dir", d)
+ assert code == 0 and "/.well-known/pyrtan/name/alice.json" in out
+ code, out, _ = run(capsys, "id", "check", str(out_file))
+ assert code == 0 and "claims alice@pyrtan.com" in out
+ claim = json.loads(out_file.read_text())
+ out_file.write_text(json.dumps({**claim, "name": "bob"}))
+ code, out, _ = run(capsys, "id", "check", str(out_file))
+ assert code == 1 and "refused" in out
+ code, out, _ = run(capsys, "id", "check", str(tmp_path / "me" / "identity.json"))
+ assert code == 0 and "identity document" in out
+
+
+def test_cli_check_identifiers(tmp_path, capsys):
+ code, out, _ = run(capsys, "id", "check", f"{VECTOR}@hub.example")
+ assert code == 0 and "well-formed address" in out and RFC8032[0][1] in out
+ code, out, _ = run(capsys, "id", "check", "alice@pyrtan.com")
+ assert code == 0 and "a name" in out
+ code, _, err = run(capsys, "id", "check", VECTOR[:-3] + "zab")
+ assert code == 1 and "pyrtan id:" in err
+
+
+def test_cli_without_an_identity(tmp_path, capsys):
+ code, _, err = run(capsys, "id", "show", "--dir", str(tmp_path / "nobody"))
+ assert code == 1 and "pyrtan id new" in err
diff --git a/uv.lock b/uv.lock index 6bd7aff..5d84524 100644 --- a/uv.lock +++ b/uv.lock
@@ -2,6 +2,91 @@ version = 1
revision = 3 requires-python = ">=3.12"
+[[package]]
+name = "cffi"
+version = "2.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
+ { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
+ { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
+ { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
+ { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
+ { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
+ { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
+ { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
+ { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
+ { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
+ { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
+ { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
+ { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
+ { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
+ { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
+ { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
+ { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
+ { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
+ { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
+ { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
+ { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
+ { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
+ { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
+ { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
+ { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
+ { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
+ { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
+ { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
+]
+
[[package]] name = "colorama" version = "0.4.6"
@@ -11,6 +96,69 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ]
+[[package]]
+name = "cryptography"
+version = "50.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" },
+ { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" },
+ { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" },
+ { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" },
+ { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" },
+ { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" },
+ { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" },
+ { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" },
+ { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" },
+ { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" },
+ { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" },
+ { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" },
+ { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" },
+ { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" },
+ { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" },
+ { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
+]
+
[[package]] name = "greenlet" version = "3.5.6"
@@ -124,6 +272,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, ]
+[[package]]
+name = "pycparser"
+version = "3.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz", hash = "sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6", size = 158785, upload-time = "2026-10-22T13:40:03.56Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600, upload-time = "2026-10-22T13:40:02.021Z" },
+]
+
[[package]] name = "pyee" version = "14.0.0"
@@ -154,6 +311,9 @@ source = { editable = "." }
browser = [ { name = "playwright" }, ]
+identity = [
+ { name = "cryptography" },
+]
[package.dev-dependencies] dev = [
@@ -161,8 +321,11 @@ dev = [
] [package.metadata]
-requires-dist = [{ name = "playwright", marker = "extra == 'browser'", specifier = ">=1.45" }]
-provides-extras = ["browser"]
+requires-dist = [
+ { name = "cryptography", marker = "extra == 'identity'", specifier = ">=42" },
+ { name = "playwright", marker = "extra == 'browser'", specifier = ">=1.45" },
+]
+provides-extras = ["browser", "identity"]
[package.metadata.requires-dev] dev = [{ name = "pytest", specifier = ">=8" }]