commit 7d35d3e2c7c66740f1c7fdd682d044e246300d21 Author: Padreug Date: Sun Sep 20 21:41:24 2026 +0200 feat: Bolt Card writer CLI for PC/SC NFC readers Provision, inspect and wipe NTAG 424 DNA Bolt Cards from a Linux PC using an LNbits /boltcards/api/v1/auth link, replacing the phone app for desks with a USB reader (ACR1252U). - pcsc.py: ctypes client for libpcsclite (no compiled deps) - ntag424.py: EV2 secure messaging (AuthenticateEV2First, ChangeKey, ChangeFileSettings, GetCardUID, WriteData) per NT4H2421Gx / AN12196 - boltcard.py: the same write/wipe sequence as bolt-card-programmer (NDEF URL, SDM 40 00E0 C1 FF12, keys 1-4 then 0, key version 1) plus local p/c verification identical to the extension's nxp424.py - cli.py: readers / read / write / wipe, keys backed up before any card mutation, uid cross-check on wipe Co-Authored-By: Claude Fable 5.1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24a6bea --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.venv/ +__pycache__/ +*.pyc +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.ruff_cache/ +result diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cc60ed4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 aiolabs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f076074 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[project] +name = "boltcard-writer" +version = "0.1.0" +description = "Write, verify and wipe Bolt Cards (NTAG 424 DNA) from a Linux PC with a USB NFC reader, straight from an LNbits auth link" +readme = "README.md" +license = "MIT" +requires-python = ">=3.11" +dependencies = [ + "click>=8.1", + "cryptography>=42", +] + +[project.scripts] +boltcard-writer = "boltcard_writer.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/boltcard_writer"] + +[dependency-groups] +dev = [ + "pytest>=8", + "ruff>=0.5", +] + +[tool.ruff] +line-length = 140 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["E501"] diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..209f00e --- /dev/null +++ b/shell.nix @@ -0,0 +1,7 @@ +# NixOS / nix users: `nix-shell` then `uv run boltcard-writer ...`. +# pcscd itself must run on the host: services.pcscd.enable = true; +{ pkgs ? import { } }: +pkgs.mkShell { + packages = [ pkgs.uv pkgs.python3 pkgs.pcsclite pkgs.pcsc-tools ]; + PCSCLITE_LIB = "${pkgs.pcsclite.lib}/lib/libpcsclite.so.1"; +} diff --git a/src/boltcard_writer/__init__.py b/src/boltcard_writer/__init__.py new file mode 100644 index 0000000..e910c6d --- /dev/null +++ b/src/boltcard_writer/__init__.py @@ -0,0 +1,3 @@ +"""boltcard-writer: program Bolt Cards from a Linux PC with a USB NFC reader.""" + +__version__ = "0.1.0" diff --git a/src/boltcard_writer/boltcard.py b/src/boltcard_writer/boltcard.py new file mode 100644 index 0000000..a448678 --- /dev/null +++ b/src/boltcard_writer/boltcard.py @@ -0,0 +1,370 @@ +"""Bolt Card provisioning on top of the NTAG 424 DNA command set. + +Mirrors what the official Bolt Card programmer app does when it consumes an +LNbits ``/boltcards/api/v1/auth?a=`` link, so a card written here is +indistinguishable from one written by the phone app: + + 1. write the NDEF URI ``?p=<32 zeros>&c=<16 zeros>`` (plain, + while the file still has free write access) + 2. authenticate with key 0 (factory: all zeros) + 3. ChangeFileSettings on the NDEF file: SDM on, UID + read counter mirrored + and encrypted with key 1 (``p``), CMAC with key 2 (``c``), writes locked + to key 0 + 4. ChangeKey 1..4 then 0 (key version 1) + 5. read the NDEF back and verify ``p``/``c`` locally with k1/k2 — the same + check the LNbits extension performs on every tap +""" + +from __future__ import annotations + +import json +import logging +import re +import sys +import urllib.parse +import urllib.request +from dataclasses import dataclass, field + +from . import ntag424 as nt +from .ntag424 import ZERO_KEY, Ntag424, Ntag424Error + +log = logging.getLogger(__name__) + +P_PLACEHOLDER = "00000000000000000000000000000000" +C_PLACEHOLDER = "0000000000000000" + +# NDEF header in front of the URL inside the NDEF file: +# 2 bytes message length + D1 01 55 00 (short URI record, no prefix) +NDEF_HEADER_LEN = 7 + +URI_PREFIXES = { + 0x00: "", + 0x01: "http://www.", + 0x02: "https://www.", + 0x03: "http://", + 0x04: "https://", + 0x05: "tel:", + 0x06: "mailto:", +} + + +class BoltcardError(Exception): + pass + + +# --- keys ------------------------------------------------------------------- + + +@dataclass +class CardKeys: + k0: bytes + k1: bytes + k2: bytes + k3: bytes + k4: bytes + lnurlw_base: str | None = None + card_name: str | None = None + raw: dict = field(default_factory=dict) + + @classmethod + def from_json(cls, obj: dict) -> CardKeys: + """Accept both spellings LNbits emits (lowercase on GET, UPPERCASE on POST).""" + lower = {str(k).lower(): v for k, v in obj.items()} + + def key(name: str) -> bytes: + v = lower.get(name) + if v is None: + raise BoltcardError(f"keys JSON is missing {name}") + v = str(v).strip() + if not re.fullmatch(r"[0-9a-fA-F]{32}", v): + raise BoltcardError(f"{name} must be 32 hex chars, got {v!r}") + return bytes.fromhex(v) + + base = lower.get("lnurlw_base") or lower.get("lnurlw") + return cls( + k0=key("k0"), + k1=key("k1"), + k2=key("k2"), + k3=key("k3"), + k4=key("k4"), + lnurlw_base=str(base) if base else None, + card_name=lower.get("card_name"), + raw=obj, + ) + + @property + def all_default(self) -> bool: + return all(k == ZERO_KEY for k in (self.k0, self.k1, self.k2, self.k3, self.k4)) + + def to_json(self) -> dict: + return { + "card_name": self.card_name, + "k0": self.k0.hex(), + "k1": self.k1.hex(), + "k2": self.k2.hex(), + "k3": self.k3.hex(), + "k4": self.k4.hex(), + "lnurlw_base": self.lnurlw_base, + } + + +def _unwrap_deeplink(source: str) -> str: + """``boltcard://program?url=`` (what the LNbits QR encodes) -> auth url.""" + if source.startswith("boltcard://"): + q = urllib.parse.urlparse(source).query + url = urllib.parse.parse_qs(q).get("url", [None])[0] + if not url: + raise BoltcardError("boltcard:// link has no url= parameter") + return url + return source + + +def load_keys(source: str) -> CardKeys: + """``source`` is an auth URL, a ``boltcard://`` deep link, a path to a JSON + file, or ``-`` for JSON on stdin. + + NOTE: fetching an LNbits auth URL rotates the one-time code server-side, + so the same link cannot be fetched twice. Copy a fresh one from the + extension if a write fails after this step (the keys themselves do not + change, and they are also saved to the backup file). + """ + source = source.strip() + if source == "-": + return CardKeys.from_json(json.load(sys.stdin)) + source = _unwrap_deeplink(source) + if source.startswith(("http://", "https://")): + log.info("fetching keys from %s", source) + req = urllib.request.Request(source, headers={"User-Agent": "boltcard-writer"}) + with urllib.request.urlopen(req, timeout=20) as resp: + body = resp.read() + try: + return CardKeys.from_json(json.loads(body)) + except json.JSONDecodeError as e: + raise BoltcardError(f"auth URL did not return JSON: {body[:200]!r}") from e + if source.startswith("{"): + return CardKeys.from_json(json.loads(source)) + with open(source, "rb") as f: + return CardKeys.from_json(json.load(f)) + + +# --- NDEF ------------------------------------------------------------------- + + +def bolt_url(lnurlw_base: str) -> str: + sep = "&" if "?" in lnurlw_base else "?" + return f"{lnurlw_base}{sep}p={P_PLACEHOLDER}&c={C_PLACEHOLDER}" + + +def ndef_uri_message(url: str) -> bytes: + payload = b"\x00" + url.encode("utf-8") # 0x00 = no URI prefix abbreviation + if len(payload) > 255: + raise BoltcardError("URL too long for a short NDEF record") + # MB|ME|SR, TNF=well-known (0xD1); type length 1; payload length; type 'U' + return bytes([0xD1, 0x01, len(payload), 0x55]) + payload + + +def parse_ndef_uri(message: bytes) -> str | None: + """Return the URI of the first record if it is a URI record, else None.""" + if len(message) < 4: + return None + header = message[0] + if header & 0x07 != 0x01: # TNF well-known + return None + short = bool(header & 0x10) + has_id = bool(header & 0x08) + i = 1 + type_len = message[i] + i += 1 + if short: + payload_len = message[i] + i += 1 + else: + payload_len = int.from_bytes(message[i : i + 4], "big") + i += 4 + id_len = 0 + if has_id: + id_len = message[i] + i += 1 + rtype = message[i : i + type_len] + i += type_len + id_len + if rtype != b"U": + return None + payload = message[i : i + payload_len] + if not payload: + return None + return URI_PREFIXES.get(payload[0], "") + payload[1:].decode("utf-8", "replace") + + +def sdm_offsets(url: str) -> tuple[int, int]: + """(PICCDataOffset, SDMMACOffset) inside the NDEF file for ``url``.""" + p = url.rfind("p=") + c = url.rfind("c=") + if p < 0 or c < 0: + raise BoltcardError("URL has no p=/c= placeholders") + return NDEF_HEADER_LEN + p + 2, NDEF_HEADER_LEN + c + 2 + + +def bolt_file_settings(picc_offset: int, mac_offset: int) -> bytes: + """ChangeFileSettings payload for a Bolt Card NDEF file. + + FileOption 0x40 SDM enabled, CommMode plain + AccessRights 00E0 ReadWrite=key0 Change=key0 | Read=free Write=key0 + SDMOptions 0xC1 UID mirror, read counter mirror, ASCII encoding + SDMAccessRights FF12 CtrRet=never | MetaRead=key1 FileRead=key2 + then PICCDataOffset, SDMMACInputOffset, SDMMACOffset (3 bytes LE each) + """ + return bytes.fromhex("4000E0C1FF12") + nt.le3(picc_offset) + nt.le3(mac_offset) + nt.le3(mac_offset) + + +def factory_file_settings() -> bytes: + """SDM off, CommMode plain, ReadWrite=free Change=key0 Read=free Write=free.""" + return bytes.fromhex("00E0EE") + + +# --- SUN verification (same math as the LNbits extension's nxp424.py) ------- + + +def decrypt_picc_data(p: bytes, k1: bytes) -> tuple[bytes, int]: + plain = nt.aes_cbc_decrypt(k1, bytes(16), p) + if plain[0] != 0xC7: + raise BoltcardError(f"PICCData tag is {plain[0]:02X}, expected C7 (wrong k1?)") + uid = plain[1:8] + counter = int.from_bytes(plain[8:11], "little") + return uid, counter + + +def sun_mac(uid: bytes, counter: int, k2: bytes) -> bytes: + sv2 = bytes.fromhex("3CC300010080") + uid + counter.to_bytes(3, "little") + ses = nt.cmac(k2, sv2) + return nt.truncate_mac(nt.cmac(ses, b"")) + + +def verify_sun_url(url: str, k1: bytes, k2: bytes) -> tuple[bytes, int]: + q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query) + p_hex = q.get("p", [""])[0] + c_hex = q.get("c", [""])[0] + if len(p_hex) != 32 or len(c_hex) != 16: + raise BoltcardError(f"URL has no usable p/c values: {url}") + uid, counter = decrypt_picc_data(bytes.fromhex(p_hex), k1) + expected = sun_mac(uid, counter, k2) + if expected != bytes.fromhex(c_hex): + raise BoltcardError("c= CMAC mismatch (wrong k2?)") + return uid, counter + + +# --- card state --------------------------------------------------------------- + + +@dataclass +class CardInfo: + uid: bytes + version: dict + key_versions: list[int] + ndef_settings: nt.FileSettings + url: str | None + atr: bytes = b"" + + @property + def looks_provisioned(self) -> bool: + return self.ndef_settings.sdm_enabled or any(v != 0 for v in self.key_versions) + + +def read_uid(card_transport, tag: Ntag424) -> bytes: + """UID via the reader's PC/SC pseudo-APDU, falling back to GetVersion.""" + try: + resp = card_transport.transmit(bytes.fromhex("FFCA000000")) + if len(resp) >= 9 and resp[-2:] == b"\x90\x00": + return resp[:-2][:7] + except Exception: # pragma: no cover - reader specific + pass + return tag.get_version()["uid"] + + +def inspect(card_transport) -> CardInfo: + tag = Ntag424(card_transport) + tag.select_application() + version = tag.get_version() + if not Ntag424.is_ntag424(version): + raise BoltcardError( + f"this is not an NTAG 424 DNA (vendor {version['hw_vendor']:02X} type {version['hw_type']:02X} major {version['hw_major']:02X})" + ) + uid = read_uid(card_transport, tag) + key_versions = [tag.get_key_version(i) for i in range(5)] + settings = tag.get_file_settings(nt.FILE_NDEF) + url = None + try: + msg = tag.read_ndef_file() + url = parse_ndef_uri(msg) if msg else None + except Ntag424Error as e: + log.debug("NDEF read failed: %s", e) + atr = b"" + try: + atr = card_transport.atr + except Exception: + pass + return CardInfo(uid=uid, version=version, key_versions=key_versions, ndef_settings=settings, url=url, atr=atr) + + +# --- flows ---------------------------------------------------------------------- + + +def provision( + card_transport, keys: CardKeys, *, old_keys: CardKeys | None = None, verify: bool = True, progress=lambda msg: None +) -> tuple[bytes, int] | None: + """Turn a factory NTAG 424 DNA into a Bolt Card. Returns (uid, counter) + from the post-write verification, or None when ``verify`` is off.""" + if not keys.lnurlw_base: + raise BoltcardError("keys JSON has no lnurlw_base — is this an LNbits auth link?") + url = bolt_url(keys.lnurlw_base) + message = ndef_uri_message(url) + picc_off, mac_off = sdm_offsets(url) + old = old_keys or CardKeys(ZERO_KEY, ZERO_KEY, ZERO_KEY, ZERO_KEY, ZERO_KEY) + + tag = Ntag424(card_transport) + + progress(f"writing NDEF URL ({len(url)} chars, p@{picc_off} c@{mac_off})") + tag.write_ndef_file(message) + + progress("authenticating with key 0") + tag.authenticate_ev2_first(0, old.k0) + + progress("enabling SDM mirroring on the NDEF file") + tag.change_file_settings(nt.FILE_NDEF, bolt_file_settings(picc_off, mac_off)) + + for no, new, prev in ((1, keys.k1, old.k1), (2, keys.k2, old.k2), (3, keys.k3, old.k3), (4, keys.k4, old.k4)): + progress(f"changing key {no}") + tag.change_key(no, new, prev, version=1) + progress("changing key 0") + tag.change_key(0, keys.k0, version=1) + + if not verify: + return None + progress("reading back and verifying p/c") + msg = tag.read_ndef_file() + read_url = parse_ndef_uri(msg) + if not read_url: + raise BoltcardError("card wrote OK but NDEF read-back is empty") + uid, counter = verify_sun_url(read_url, keys.k1, keys.k2) + progress(f"verified: uid {uid.hex().upper()} counter {counter}") + return uid, counter + + +def wipe(card_transport, keys: CardKeys, *, progress=lambda msg: None) -> None: + """Reset a Bolt Card to factory: SDM off, all keys zero, empty NDEF.""" + tag = Ntag424(card_transport) + tag.select_application() + + progress("authenticating with key 0") + tag.authenticate_ev2_first(0, keys.k0) + + progress("restoring factory file settings") + tag.change_file_settings(nt.FILE_NDEF, factory_file_settings()) + + for no, cur in ((1, keys.k1), (2, keys.k2), (3, keys.k3), (4, keys.k4)): + progress(f"resetting key {no}") + tag.change_key(no, ZERO_KEY, cur, version=0) + progress("resetting key 0") + tag.change_key(0, ZERO_KEY, version=0) + + progress("clearing NDEF") + tag.write_ndef_file(b"") diff --git a/src/boltcard_writer/cli.py b/src/boltcard_writer/cli.py new file mode 100644 index 0000000..80a78c4 --- /dev/null +++ b/src/boltcard_writer/cli.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +import datetime as dt +import json +import logging +import os +import sys +import urllib.error +from pathlib import Path + +import click + +from . import __version__, boltcard, pcsc +from .boltcard import BoltcardError, CardKeys +from .ntag424 import Ntag424Error + +log = logging.getLogger("boltcard_writer") + +DEFAULT_BACKUP_DIR = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")) / "boltcard-writer" / "backups" + + +def _setup_logging(debug: bool) -> None: + logging.basicConfig( + level=logging.DEBUG if debug else logging.INFO, + format="%(message)s" if not debug else "%(levelname)s %(name)s: %(message)s", + stream=sys.stderr, + ) + + +def _die(msg: str, code: int = 1) -> None: + click.secho(f"error: {msg}", fg="red", err=True) + sys.exit(code) + + +def _run(fn): + """Run ``fn`` translating the known failure modes into short messages.""" + try: + return fn() + except pcsc.PCSCUnavailable as e: + _die(str(e)) + except pcsc.PCSCError as e: + _die(str(e)) + except Ntag424Error as e: + _die(f"card: {e}") + except BoltcardError as e: + _die(str(e)) + except urllib.error.URLError as e: + _die(f"could not fetch keys: {e}") + except KeyboardInterrupt: + _die("interrupted", 130) + + +def _print_info(info: boltcard.CardInfo) -> None: + v = info.version + click.echo(f" UID: {info.uid.hex().upper()}") + click.echo( + f" chip: NTAG 424 DNA (HW {v['hw_major']:02X}.{v['hw_minor']:02X}, " + f"SW {v['sw_major']:02X}.{v['sw_minor']:02X}, storage 0x{v['hw_storage']:02X})" + ) + if info.atr: + click.echo(f" ATR: {info.atr.hex().upper()}") + click.echo(f" key versions: {' '.join(f'k{i}={kv}' for i, kv in enumerate(info.key_versions))}") + fs = info.ndef_settings + acc = fs.access() + click.echo( + f" NDEF file: SDM {'ON' if fs.sdm_enabled else 'off'}, comm {fs.comm_mode}, " + f"read={acc['read']} write={acc['write']} change={acc['change']}" + ) + click.echo(f" NDEF URL: {info.url or '(empty)'}") + state = "PROVISIONED (Bolt Card)" if info.looks_provisioned else "blank / factory" + click.echo(f" state: {state}") + + +def _wait_for_card(ctx: pcsc.Context, reader: str, timeout: float | None) -> pcsc.Card: + click.echo(f"Tap the card on: {reader}") + return ctx.wait_for_card(reader, timeout=timeout) + + +def _backup(keys: CardKeys, backup_dir: Path, label: str) -> Path: + backup_dir.mkdir(parents=True, exist_ok=True) + stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + name = "".join(c if c.isalnum() or c in "-_" else "_" for c in (keys.card_name or "card")) + path = backup_dir / f"{stamp}-{label}-{name}.json" + with open(path, "w") as f: + json.dump(keys.to_json(), f, indent=2) + try: + os.chmod(path, 0o600) + except OSError: + pass + return path + + +def _expected_uid(keys: CardKeys) -> bytes | None: + uid = keys.raw.get("uid") or keys.raw.get("UID") + if not uid: + return None + try: + return bytes.fromhex(str(uid)) + except ValueError: + return None + + +@click.group(context_settings={"help_option_names": ["-h", "--help"]}) +@click.option( + "-r", "--reader", "reader_filter", metavar="SUBSTR", help="pick the reader whose name contains SUBSTR (default: the PICC reader)" +) +@click.option("--debug", is_flag=True, help="trace every APDU to stderr (paste this when reporting a problem)") +@click.option("--timeout", type=float, default=None, help="seconds to wait for a card (default: forever)") +@click.version_option(__version__) +@click.pass_context +def main(ctx: click.Context, reader_filter: str | None, debug: bool, timeout: float | None) -> None: + """Write, verify and wipe Bolt Cards (NTAG 424 DNA) with a PC/SC NFC reader.""" + _setup_logging(debug) + ctx.obj = {"reader": reader_filter, "timeout": timeout} + + +@main.command() +def readers() -> None: + """List PC/SC readers and which one will be used.""" + + def go(): + with pcsc.Context() as ctx: + names = ctx.list_readers() + if not names: + _die(pcsc.HINTS[pcsc.SCARD_E_NO_READERS_AVAILABLE]) + chosen = ctx.pick_reader(click.get_current_context().obj["reader"]) + for n in names: + click.echo(("* " if n == chosen else " ") + n) + + _run(go) + + +@main.command() +@click.option("--keys", "keys_source", metavar="FILE", help="keys JSON (a backup file) to verify p=/c= against") +@click.pass_obj +def read(obj, keys_source: str | None) -> None: + """Show what is on the card: UID, key versions, SDM state, NDEF URL.""" + + def go(): + keys = boltcard.load_keys(keys_source) if keys_source else None + with pcsc.Context() as ctx: + reader = ctx.pick_reader(obj["reader"]) + with _wait_for_card(ctx, reader, obj["timeout"]) as card: + info = boltcard.inspect(card) + click.echo("Card:") + _print_info(info) + if keys and info.url: + uid, counter = boltcard.verify_sun_url(info.url, keys.k1, keys.k2) + click.secho(f" SUN check: OK (uid {uid.hex().upper()}, counter {counter})", fg="green") + + _run(go) + + +@main.command() +@click.argument("source") +@click.option("-y", "--yes", is_flag=True, help="do not ask for confirmation") +@click.option("--force", is_flag=True, help="write even if the card looks already provisioned") +@click.option("--no-verify", is_flag=True, help="skip the read-back p/c verification") +@click.option("--backup-dir", type=click.Path(path_type=Path), default=DEFAULT_BACKUP_DIR, show_default=True) +@click.pass_obj +def write(obj, source: str, yes: bool, force: bool, no_verify: bool, backup_dir: Path) -> None: + """Program a blank NTAG 424 DNA as a Bolt Card. + + SOURCE is the LNbits "Keys / Auth link" URL + (https:///boltcards/api/v1/auth?a=...), the boltcard:// deep link + behind the QR code, a keys JSON file, or - for JSON on stdin. + + The auth link is single-use: LNbits rotates it on every fetch. The keys are + saved to a backup file before the card is touched, so a failed write can be + retried with `write `. + """ + + def go(): + keys = boltcard.load_keys(source) + if not keys.lnurlw_base: + _die("this JSON has no lnurlw_base; use the Keys / Auth link from the LNbits card dialog") + if keys.all_default: + _die("all keys are zero; LNbits generated no keys for this card") + path = _backup(keys, backup_dir, "write") + click.echo(f"Card: {keys.card_name or '(unnamed)'}") + click.echo(f"LNURLw: {keys.lnurlw_base}") + click.echo(f"Keys backed up to {path}") + + with pcsc.Context() as ctx: + reader = ctx.pick_reader(obj["reader"]) + with _wait_for_card(ctx, reader, obj["timeout"]) as card: + info = boltcard.inspect(card) + _print_info(info) + if info.looks_provisioned and not force: + _die( + "card already looks provisioned; run `wipe` with its keys first (or --force if you know the keys are factory zeros)" + ) + if not info.looks_provisioned and info.url: + click.echo(" (existing NDEF content will be overwritten)") + if not yes: + click.confirm("Write this card now? Keep it still on the reader until done", abort=True) + click.echo() + result = boltcard.provision(card, keys, verify=not no_verify, progress=lambda m: click.echo(f" … {m}")) + click.echo() + if result: + uid, counter = result + click.secho(f"Done. Bolt Card {uid.hex().upper()} written and verified (counter {counter}).", fg="green") + else: + click.secho("Done. Card written (verification skipped).", fg="green") + click.echo("You can remove the card. Test it by tapping it on a Lightning POS / wallet.") + + _run(go) + + +@main.command() +@click.argument("source") +@click.option("-y", "--yes", is_flag=True, help="do not ask for confirmation") +@click.option("--backup-dir", type=click.Path(path_type=Path), default=DEFAULT_BACKUP_DIR, show_default=True) +@click.pass_obj +def wipe(obj, source: str, yes: bool, backup_dir: Path) -> None: + """Reset a Bolt Card to factory state (keys to zero, SDM off, NDEF cleared). + + SOURCE is the WIPE JSON from the LNbits card dialog (paste it into a file, + or pass - and pipe it on stdin), the card's auth link, or a backup file + written by `write`. + """ + + def go(): + keys = boltcard.load_keys(source) + if keys.all_default: + _die("all keys in this JSON are zero; nothing to wipe with") + path = _backup(keys, backup_dir, "wipe") + click.echo(f"Keys backed up to {path}") + want_uid = _expected_uid(keys) + + with pcsc.Context() as ctx: + reader = ctx.pick_reader(obj["reader"]) + with _wait_for_card(ctx, reader, obj["timeout"]) as card: + info = boltcard.inspect(card) + _print_info(info) + if want_uid and want_uid != info.uid: + _die(f"this JSON is for card {want_uid.hex().upper()}, but the card on the reader is {info.uid.hex().upper()}") + if not info.looks_provisioned: + click.echo("Card already looks factory-fresh; nothing to do.") + return + if not yes: + click.confirm("Wipe this card now? Keep it still on the reader until done", abort=True) + click.echo() + boltcard.wipe(card, keys, progress=lambda m: click.echo(f" … {m}")) + click.echo() + click.secho("Done. Card reset to factory state; it can be deleted in LNbits and re-used.", fg="green") + + _run(go) + + +if __name__ == "__main__": + main() diff --git a/src/boltcard_writer/ntag424.py b/src/boltcard_writer/ntag424.py new file mode 100644 index 0000000..3a6b79d --- /dev/null +++ b/src/boltcard_writer/ntag424.py @@ -0,0 +1,425 @@ +"""NXP NTAG 424 DNA command set — the subset a Bolt Card programmer needs. + +References: + * NT4H2421Gx data sheet (command set, secure messaging) + * AN12196 "NTAG 424 DNA and NTAG 424 DNA TagTamper features and hints" + (worked examples; the unit tests replay its vectors) + +Secure messaging is EV2 "CommMode.Full": command data AES-CBC encrypted under +the session ENC key with a per-command IV, then CMAC'd under the session MAC +key together with the command counter and transaction id. Responses carry a +truncated CMAC (and encrypted data for commands that return secrets). +""" + +from __future__ import annotations + +import logging +import os +import zlib +from dataclasses import dataclass + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.cmac import CMAC + +log = logging.getLogger(__name__) + +ZERO_KEY = bytes(16) +NDEF_AID = bytes.fromhex("D2760000850101") +FID_CC = bytes.fromhex("E103") +FID_NDEF = bytes.fromhex("E104") +FILE_CC = 0x01 +FILE_NDEF = 0x02 +FILE_PROPRIETARY = 0x03 + +# Native command codes (wrapped: CLA 0x90, INS = code, P1 = P2 = 0x00) +CMD_GET_VERSION = 0x60 +CMD_ADDITIONAL_FRAME = 0xAF +CMD_AUTH_EV2_FIRST = 0x71 +CMD_GET_KEY_VERSION = 0x64 +CMD_GET_FILE_SETTINGS = 0xF5 +CMD_CHANGE_FILE_SETTINGS = 0x5F +CMD_CHANGE_KEY = 0xC4 +CMD_GET_CARD_UID = 0x51 +CMD_WRITE_DATA = 0x8D + +SW_OK = 0x9100 +SW_MORE = 0x91AF +SW_ISO_OK = 0x9000 + +STATUS_NAMES = { + 0x9100: "OPERATION_OK", + 0x91AF: "ADDITIONAL_FRAME", + 0x910C: "NO_CHANGES", + 0x911C: "ILLEGAL_COMMAND_CODE", + 0x911E: "INTEGRITY_ERROR (bad MAC/CRC, or the key you used is wrong)", + 0x9140: "NO_SUCH_KEY", + 0x917E: "LENGTH_ERROR", + 0x919D: "PERMISSION_DENIED (authenticate with the right key first)", + 0x919E: "PARAMETER_ERROR", + 0x91AD: "AUTHENTICATION_DELAY (too many failed auths; wait a moment and retry)", + 0x91AE: "AUTHENTICATION_ERROR (wrong key)", + 0x91BE: "BOUNDARY_ERROR", + 0x91CA: "COMMAND_ABORTED", + 0x91EE: "MEMORY_ERROR", + 0x91F0: "FILE_NOT_FOUND", + 0x9000: "OK", + 0x6700: "WRONG_LENGTH", + 0x6982: "SECURITY_STATUS_NOT_SATISFIED", + 0x6985: "CONDITIONS_OF_USE_NOT_SATISFIED", + 0x6A82: "FILE_OR_APPLICATION_NOT_FOUND", + 0x6A86: "INCORRECT_P1_P2", + 0x6E00: "CLASS_NOT_SUPPORTED", +} + + +class Ntag424Error(Exception): + def __init__(self, what: str, status: int | None = None, detail: str | None = None): + self.status = status + msg = what + if status is not None: + msg += f": SW {status:04X} {STATUS_NAMES.get(status, 'unknown status')}" + if detail: + msg += f" ({detail})" + super().__init__(msg) + + +# --- crypto helpers --------------------------------------------------------- + + +def aes_cbc_encrypt(key: bytes, iv: bytes, data: bytes) -> bytes: + enc = Cipher(algorithms.AES(key), modes.CBC(iv)).encryptor() + return enc.update(data) + enc.finalize() + + +def aes_cbc_decrypt(key: bytes, iv: bytes, data: bytes) -> bytes: + dec = Cipher(algorithms.AES(key), modes.CBC(iv)).decryptor() + return dec.update(data) + dec.finalize() + + +def aes_ecb_encrypt_block(key: bytes, block: bytes) -> bytes: + enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor() + return enc.update(block) + enc.finalize() + + +def cmac(key: bytes, data: bytes = b"") -> bytes: + c = CMAC(algorithms.AES(key)) + c.update(data) + return c.finalize() + + +def truncate_mac(mac16: bytes) -> bytes: + """MACt: the odd-indexed bytes (1, 3, ..., 15) of the full CMAC.""" + return bytes(mac16[1::2]) + + +def rotate_left(b: bytes) -> bytes: + return b[1:] + b[:1] + + +def xor(a: bytes, b: bytes) -> bytes: + return bytes(x ^ y for x, y in zip(a, b, strict=True)) + + +def pad_enc(data: bytes) -> bytes: + """ISO/IEC 9797-1 method 2: 0x80 then zeros, always at least one byte.""" + pad_len = 16 - (len(data) % 16) + return data + b"\x80" + bytes(pad_len - 1) + + +def unpad_enc(data: bytes) -> bytes: + idx = data.rfind(b"\x80") + if idx < 0: + raise Ntag424Error("bad padding in decrypted response") + return data[:idx] + + +def crc32_nk(data: bytes) -> bytes: + """CRC32 as used by ChangeKey: standard CRC-32 without the final XOR + (a.k.a. JAMCRC), serialised LSB first.""" + return ((zlib.crc32(data) ^ 0xFFFFFFFF) & 0xFFFFFFFF).to_bytes(4, "little") + + +def le3(n: int) -> bytes: + return n.to_bytes(3, "little") + + +# --- session ---------------------------------------------------------------- + + +@dataclass +class Session: + key_no: int + ti: bytes + enc_key: bytes + mac_key: bytes + cmd_ctr: int = 0 + + def _iv(self, prefix: bytes) -> bytes: + block = prefix + self.ti + self.cmd_ctr.to_bytes(2, "little") + bytes(8) + return aes_ecb_encrypt_block(self.enc_key, block) + + def iv_cmd(self) -> bytes: + return self._iv(b"\xa5\x5a") + + def iv_resp(self) -> bytes: + return self._iv(b"\x5a\xa5") + + def encrypt(self, data: bytes) -> bytes: + if not data: + return b"" + return aes_cbc_encrypt(self.enc_key, self.iv_cmd(), pad_enc(data)) + + def decrypt(self, data: bytes) -> bytes: + if not data: + return b"" + return unpad_enc(aes_cbc_decrypt(self.enc_key, self.iv_resp(), data)) + + def mac_cmd(self, cmd: int, header: bytes, enc_data: bytes) -> bytes: + msg = bytes([cmd]) + self.cmd_ctr.to_bytes(2, "little") + self.ti + header + enc_data + return truncate_mac(cmac(self.mac_key, msg)) + + def mac_resp(self, sw2: int, data: bytes) -> bytes: + msg = bytes([sw2]) + self.cmd_ctr.to_bytes(2, "little") + self.ti + data + return truncate_mac(cmac(self.mac_key, msg)) + + +def derive_session_keys(key: bytes, rnd_a: bytes, rnd_b: bytes) -> tuple[bytes, bytes]: + mix = rnd_a[0:2] + xor(rnd_a[2:8], rnd_b[0:6]) + rnd_b[6:16] + rnd_a[8:16] + enc = cmac(key, bytes.fromhex("A55A00010080") + mix) + mac = cmac(key, bytes.fromhex("5AA500010080") + mix) + return enc, mac + + +# --- file settings ------------------------------------------------------------ + + +@dataclass +class FileSettings: + file_type: int + file_option: int + access_rights: bytes # 2 bytes as on the wire: [RW|Change], [Read|Write] + file_size: int + sdm_options: int | None = None + sdm_access_rights: bytes | None = None + raw: bytes = b"" + + @property + def sdm_enabled(self) -> bool: + return bool(self.file_option & 0x40) + + @property + def comm_mode(self) -> str: + return {0: "plain", 1: "mac", 3: "full"}.get(self.file_option & 0x03, "?") + + def access(self) -> dict[str, str]: + def cond(n: int) -> str: + return {0xE: "free", 0xF: "never"}.get(n, f"key{n}") + + rw, ch = self.access_rights[0] >> 4, self.access_rights[0] & 0xF + rd, wr = self.access_rights[1] >> 4, self.access_rights[1] & 0xF + return {"read": cond(rd), "write": cond(wr), "readwrite": cond(rw), "change": cond(ch)} + + @classmethod + def parse(cls, data: bytes) -> FileSettings: + fs = cls( + file_type=data[0], + file_option=data[1], + access_rights=data[2:4], + file_size=int.from_bytes(data[4:7], "little"), + raw=data, + ) + if fs.sdm_enabled and len(data) >= 10: + fs.sdm_options = data[7] + fs.sdm_access_rights = data[8:10] + return fs + + +# --- the card ----------------------------------------------------------------- + + +class Transport: + """Anything with ``transmit(apdu: bytes) -> bytes`` (response incl. SW).""" + + def transmit(self, apdu: bytes) -> bytes: # pragma: no cover - interface + raise NotImplementedError + + +class Ntag424: + def __init__(self, transport: Transport): + self.t = transport + self.session: Session | None = None + + # -- raw APDU plumbing -- + + def _xfer(self, apdu: bytes, expect: int | tuple[int, ...] = SW_OK, what: str = "") -> bytes: + resp = self.t.transmit(apdu) + if len(resp) < 2: + raise Ntag424Error(f"{what or 'command'}: short response") + sw = int.from_bytes(resp[-2:], "big") + ok = (expect,) if isinstance(expect, int) else expect + if sw not in ok: + if sw != SW_MORE: + self.session = None + raise Ntag424Error(what or f"cmd {apdu[1]:02X}", sw) + return resp[:-2] + + def native(self, cmd: int, data: bytes = b"", expect: int | tuple[int, ...] = SW_OK, what: str = "") -> bytes: + apdu = bytes([0x90, cmd, 0x00, 0x00, len(data)]) + data + b"\x00" + if not data: + apdu = bytes([0x90, cmd, 0x00, 0x00, 0x00]) + return self._xfer(apdu, expect, what) + + def secure(self, cmd: int, header: bytes, data: bytes = b"", expect_mac: bool = True, what: str = "") -> bytes: + """Send ``cmd`` in CommMode.Full; returns decrypted response data.""" + s = self.session + if s is None: + raise Ntag424Error(f"{what or 'secure command'} requires authentication") + enc = s.encrypt(data) + mac = s.mac_cmd(cmd, header, enc) + body = header + enc + mac + apdu = bytes([0x90, cmd, 0x00, 0x00, len(body)]) + body + b"\x00" + s.cmd_ctr += 1 + resp = self._xfer(apdu, SW_OK, what) + if len(resp) == 0: + if expect_mac: + raise Ntag424Error(f"{what}: response without MAC") + return b"" + if len(resp) < 8: + raise Ntag424Error(f"{what}: response too short for a MAC") + payload, rmac = resp[:-8], resp[-8:] + if s.mac_resp(0x00, payload) != rmac: + self.session = None + raise Ntag424Error(f"{what}: response MAC mismatch") + return s.decrypt(payload) if payload else b"" + + # -- ISO 7816 layer -- + + def select_application(self) -> None: + self._xfer(bytes([0x00, 0xA4, 0x04, 0x00, len(NDEF_AID)]) + NDEF_AID + b"\x00", SW_ISO_OK, "ISOSelectFile(app)") + + def select_file(self, fid: bytes) -> None: + self._xfer(bytes([0x00, 0xA4, 0x00, 0x00, 0x02]) + fid + b"\x00", SW_ISO_OK, f"ISOSelectFile({fid.hex()})") + + def iso_update_binary(self, offset: int, data: bytes) -> None: + for i in range(0, len(data), 200): + chunk = data[i : i + 200] + off = offset + i + self._xfer( + bytes([0x00, 0xD6, off >> 8, off & 0xFF, len(chunk)]) + chunk, + SW_ISO_OK, + "ISOUpdateBinary", + ) + + def iso_read_binary(self, offset: int, length: int) -> bytes: + out = b"" + while len(out) < length: + n = min(200, length - len(out)) + off = offset + len(out) + out += self._xfer(bytes([0x00, 0xB0, off >> 8, off & 0xFF, n]), SW_ISO_OK, "ISOReadBinary") + return out + + def read_ndef_file(self) -> bytes: + """Return the NDEF message bytes (without the 2-byte length prefix). + + Reading through ISO ReadBinary applies SDM mirroring, so on a + provisioned card the URL comes back with live p= and c= values. + """ + self.select_application() + self.select_file(FID_NDEF) + size = int.from_bytes(self.iso_read_binary(0, 2), "big") + if size == 0: + return b"" + return self.iso_read_binary(2, size) + + def write_ndef_file(self, message: bytes) -> None: + """Plain write of an NDEF message; needs free Write access (factory state).""" + if len(message) > 254: + raise Ntag424Error("NDEF message too long for the 256-byte NDEF file") + self.select_application() + self.select_file(FID_NDEF) + image = len(message).to_bytes(2, "big") + message + self.iso_update_binary(0, image) + + # -- plain native commands -- + + def get_version(self) -> dict[str, object]: + p1 = self.native(CMD_GET_VERSION, expect=SW_MORE, what="GetVersion") + p2 = self.native(CMD_ADDITIONAL_FRAME, expect=SW_MORE, what="GetVersion") + p3 = self.native(CMD_ADDITIONAL_FRAME, expect=SW_OK, what="GetVersion") + return { + "hw_vendor": p1[0], + "hw_type": p1[1], + "hw_subtype": p1[2], + "hw_major": p1[3], + "hw_minor": p1[4], + "hw_storage": p1[5], + "hw_protocol": p1[6], + "sw_major": p2[3], + "sw_minor": p2[4], + "uid": p3[0:7], + "batch": p3[7:12], + "raw": p1 + p2 + p3, + } + + @staticmethod + def is_ntag424(version: dict[str, object]) -> bool: + return version["hw_vendor"] == 0x04 and version["hw_type"] == 0x04 and version["hw_major"] == 0x30 + + def get_key_version(self, key_no: int) -> int: + return self.native(CMD_GET_KEY_VERSION, bytes([key_no]), what=f"GetKeyVersion({key_no})")[0] + + def get_file_settings(self, file_no: int = FILE_NDEF) -> FileSettings: + return FileSettings.parse(self.native(CMD_GET_FILE_SETTINGS, bytes([file_no]), what="GetFileSettings")) + + # -- authentication -- + + def authenticate_ev2_first(self, key_no: int, key: bytes, rnd_a: bytes | None = None) -> Session: + if len(key) != 16: + raise Ntag424Error("key must be 16 bytes") + self.session = None + e_rnd_b = self.native(CMD_AUTH_EV2_FIRST, bytes([key_no, 0x00]), expect=SW_MORE, what="AuthenticateEV2First") + if len(e_rnd_b) != 16: + raise Ntag424Error("AuthenticateEV2First: unexpected part-1 length") + rnd_b = aes_cbc_decrypt(key, bytes(16), e_rnd_b) + rnd_a = rnd_a or os.urandom(16) + e_part2 = aes_cbc_encrypt(key, bytes(16), rnd_a + rotate_left(rnd_b)) + resp = self.native(CMD_ADDITIONAL_FRAME, e_part2, expect=SW_OK, what="AuthenticateEV2First part 2") + if len(resp) != 32: + raise Ntag424Error("AuthenticateEV2First: unexpected part-2 length") + plain = aes_cbc_decrypt(key, bytes(16), resp) + ti, rnd_a_p = plain[0:4], plain[4:20] + if rnd_a_p != rotate_left(rnd_a): + raise Ntag424Error("AuthenticateEV2First: card failed to prove key knowledge (RndA' mismatch)") + enc_key, mac_key = derive_session_keys(key, rnd_a, rnd_b) + self.session = Session(key_no=key_no, ti=ti, enc_key=enc_key, mac_key=mac_key, cmd_ctr=0) + log.debug("authenticated with key %d, TI=%s", key_no, ti.hex().upper()) + return self.session + + # -- secure commands -- + + def get_card_uid(self) -> bytes: + return self.secure(CMD_GET_CARD_UID, b"", what="GetCardUID") + + def change_file_settings(self, file_no: int, settings: bytes) -> None: + self.secure(CMD_CHANGE_FILE_SETTINGS, bytes([file_no]), settings, what="ChangeFileSettings") + + def write_data(self, file_no: int, offset: int, data: bytes) -> None: + header = bytes([file_no]) + le3(offset) + le3(len(data)) + self.secure(CMD_WRITE_DATA, header, data, what="WriteData") + + def change_key(self, key_no: int, new_key: bytes, old_key: bytes = ZERO_KEY, version: int = 0) -> None: + """ChangeKey. Must be authenticated with key 0. + + Changing key 0 itself ends the session (the card answers with no MAC). + """ + if self.session is None or self.session.key_no != 0: + raise Ntag424Error("ChangeKey requires authentication with key 0") + if len(new_key) != 16 or len(old_key) != 16: + raise Ntag424Error("keys must be 16 bytes") + if key_no == 0: + data = new_key + bytes([version]) + else: + data = xor(new_key, old_key) + bytes([version]) + crc32_nk(new_key) + self.secure(CMD_CHANGE_KEY, bytes([key_no]), data, expect_mac=(key_no != 0), what=f"ChangeKey({key_no})") + if key_no == 0: + self.session = None diff --git a/src/boltcard_writer/pcsc.py b/src/boltcard_writer/pcsc.py new file mode 100644 index 0000000..b724e4c --- /dev/null +++ b/src/boltcard_writer/pcsc.py @@ -0,0 +1,357 @@ +"""Minimal PC/SC client over ``libpcsclite.so`` via ctypes. + +Only what a card programmer needs: list readers, wait for a card, transmit +APDUs. No compiled extension, so the only system requirement is pcsclite +itself (``pacman -S pcsclite ccid`` on Arch, ``services.pcscd`` on NixOS). + +Linux-only: ``DWORD`` is ``unsigned long`` and ``LONG`` is ``long`` in +pcsclite's ``wintypes.h`` on Linux (they are 32-bit on macOS). +""" + +from __future__ import annotations + +import ctypes +import ctypes.util +import logging +import os +import time +from dataclasses import dataclass + +log = logging.getLogger(__name__) + +DWORD = ctypes.c_ulong +LONG = ctypes.c_long +SCARDCONTEXT = ctypes.c_long +SCARDHANDLE = ctypes.c_long + +SCARD_SCOPE_SYSTEM = 0x0002 +SCARD_SHARE_SHARED = 0x0002 +SCARD_SHARE_EXCLUSIVE = 0x0001 +SCARD_PROTOCOL_T0 = 0x0001 +SCARD_PROTOCOL_T1 = 0x0002 +SCARD_PROTOCOL_ANY = SCARD_PROTOCOL_T0 | SCARD_PROTOCOL_T1 +SCARD_LEAVE_CARD = 0x0000 +SCARD_RESET_CARD = 0x0001 +SCARD_UNPOWER_CARD = 0x0002 + +SCARD_S_SUCCESS = 0x00000000 +SCARD_E_INVALID_HANDLE = 0x80100003 +SCARD_E_INSUFFICIENT_BUFFER = 0x80100008 +SCARD_E_UNKNOWN_READER = 0x80100009 +SCARD_E_TIMEOUT = 0x8010000A +SCARD_E_SHARING_VIOLATION = 0x8010000B +SCARD_E_NO_SMARTCARD = 0x8010000C +SCARD_E_PROTO_MISMATCH = 0x8010000F +SCARD_E_NOT_TRANSACTED = 0x80100016 +SCARD_E_READER_UNAVAILABLE = 0x80100017 +SCARD_E_NO_SERVICE = 0x8010001D +SCARD_E_SERVICE_STOPPED = 0x8010001E +SCARD_E_NO_READERS_AVAILABLE = 0x8010002E +SCARD_E_COMM_DATA_LOST = 0x8010002F +SCARD_W_UNRESPONSIVE_CARD = 0x80100066 +SCARD_W_UNPOWERED_CARD = 0x80100067 +SCARD_W_REMOVED_CARD = 0x80100069 + +ERROR_NAMES = { + SCARD_E_INVALID_HANDLE: "SCARD_E_INVALID_HANDLE", + SCARD_E_INSUFFICIENT_BUFFER: "SCARD_E_INSUFFICIENT_BUFFER", + SCARD_E_UNKNOWN_READER: "SCARD_E_UNKNOWN_READER", + SCARD_E_TIMEOUT: "SCARD_E_TIMEOUT", + SCARD_E_SHARING_VIOLATION: "SCARD_E_SHARING_VIOLATION", + SCARD_E_NO_SMARTCARD: "SCARD_E_NO_SMARTCARD", + SCARD_E_PROTO_MISMATCH: "SCARD_E_PROTO_MISMATCH", + SCARD_E_NOT_TRANSACTED: "SCARD_E_NOT_TRANSACTED", + SCARD_E_READER_UNAVAILABLE: "SCARD_E_READER_UNAVAILABLE", + SCARD_E_NO_SERVICE: "SCARD_E_NO_SERVICE", + SCARD_E_SERVICE_STOPPED: "SCARD_E_SERVICE_STOPPED", + SCARD_E_NO_READERS_AVAILABLE: "SCARD_E_NO_READERS_AVAILABLE", + SCARD_E_COMM_DATA_LOST: "SCARD_E_COMM_DATA_LOST", + SCARD_W_UNRESPONSIVE_CARD: "SCARD_W_UNRESPONSIVE_CARD", + SCARD_W_UNPOWERED_CARD: "SCARD_W_UNPOWERED_CARD", + SCARD_W_REMOVED_CARD: "SCARD_W_REMOVED_CARD", +} + +HINTS = { + SCARD_E_NO_SERVICE: ("pcscd is not running. Arch: `sudo systemctl enable --now pcscd.socket`. NixOS: `services.pcscd.enable = true;`"), + SCARD_E_SERVICE_STOPPED: "pcscd stopped; restart it: `sudo systemctl restart pcscd`", + SCARD_E_NO_READERS_AVAILABLE: ( + "no smart card reader detected. Is the ACR1252U plugged in and the `ccid` " + "driver package installed? Check with `lsusb` and `pcsc_scan`." + ), + SCARD_E_SHARING_VIOLATION: ("another program holds the reader (pcsc_scan, another writer instance?)"), + SCARD_E_READER_UNAVAILABLE: "the reader was unplugged", +} + +# Card temporarily absent / not ready: keep polling. +_TRANSIENT_ON_CONNECT = { + SCARD_E_NO_SMARTCARD, + SCARD_W_REMOVED_CARD, + SCARD_W_UNRESPONSIVE_CARD, + SCARD_W_UNPOWERED_CARD, + SCARD_E_PROTO_MISMATCH, + SCARD_E_COMM_DATA_LOST, + SCARD_E_NOT_TRANSACTED, +} + + +class PCSCError(Exception): + def __init__(self, func: str, code: int): + self.func = func + self.code = code & 0xFFFFFFFF + name = ERROR_NAMES.get(self.code, f"0x{self.code:08X}") + msg = f"{func} failed: {name}" + hint = HINTS.get(self.code) + if hint: + msg += f" — {hint}" + super().__init__(msg) + + +class PCSCUnavailable(Exception): + """libpcsclite could not be loaded.""" + + +class SCARD_IO_REQUEST(ctypes.Structure): + _fields_ = [("dwProtocol", ctypes.c_ulong), ("cbPciLength", ctypes.c_ulong)] + + +_lib: ctypes.CDLL | None = None + + +def _load() -> ctypes.CDLL: + global _lib + if _lib is not None: + return _lib + candidates = [ + os.environ.get("PCSCLITE_LIB"), + "libpcsclite.so.1", + ctypes.util.find_library("pcsclite"), + "libpcsclite.so", + ] + lib = None + for name in candidates: + if not name: + continue + try: + lib = ctypes.CDLL(name) + log.debug("loaded %s", name) + break + except OSError: + continue + if lib is None: + raise PCSCUnavailable( + "libpcsclite.so.1 not found. Install pcsclite (Arch: `sudo pacman -S " + "pcsclite ccid`; NixOS: use the repo's shell.nix) or point PCSCLITE_LIB " + "at the library." + ) + + lib.SCardEstablishContext.argtypes = [ + DWORD, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.POINTER(SCARDCONTEXT), + ] + lib.SCardEstablishContext.restype = LONG + lib.SCardReleaseContext.argtypes = [SCARDCONTEXT] + lib.SCardReleaseContext.restype = LONG + lib.SCardListReaders.argtypes = [ + SCARDCONTEXT, + ctypes.c_char_p, + ctypes.c_char_p, + ctypes.POINTER(DWORD), + ] + lib.SCardListReaders.restype = LONG + lib.SCardConnect.argtypes = [ + SCARDCONTEXT, + ctypes.c_char_p, + DWORD, + DWORD, + ctypes.POINTER(SCARDHANDLE), + ctypes.POINTER(DWORD), + ] + lib.SCardConnect.restype = LONG + lib.SCardDisconnect.argtypes = [SCARDHANDLE, DWORD] + lib.SCardDisconnect.restype = LONG + lib.SCardStatus.argtypes = [ + SCARDHANDLE, + ctypes.c_char_p, + ctypes.POINTER(DWORD), + ctypes.POINTER(DWORD), + ctypes.POINTER(DWORD), + ctypes.c_char_p, + ctypes.POINTER(DWORD), + ] + lib.SCardStatus.restype = LONG + lib.SCardTransmit.argtypes = [ + SCARDHANDLE, + ctypes.POINTER(SCARD_IO_REQUEST), + ctypes.c_char_p, + DWORD, + ctypes.POINTER(SCARD_IO_REQUEST), + ctypes.c_char_p, + ctypes.POINTER(DWORD), + ] + lib.SCardTransmit.restype = LONG + _lib = lib + return lib + + +def _check(func: str, rv: int) -> None: + code = rv & 0xFFFFFFFF + if code != SCARD_S_SUCCESS: + raise PCSCError(func, code) + + +class Context: + """A pcscd connection. Use as a context manager.""" + + def __init__(self) -> None: + self._lib = _load() + self._h = SCARDCONTEXT(0) + _check( + "SCardEstablishContext", + self._lib.SCardEstablishContext(SCARD_SCOPE_SYSTEM, None, None, ctypes.byref(self._h)), + ) + + def __enter__(self) -> Context: + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + def close(self) -> None: + if self._h.value: + self._lib.SCardReleaseContext(self._h) + self._h = SCARDCONTEXT(0) + + def list_readers(self) -> list[str]: + size = DWORD(0) + rv = self._lib.SCardListReaders(self._h, None, None, ctypes.byref(size)) & 0xFFFFFFFF + if rv == SCARD_E_NO_READERS_AVAILABLE: + return [] + _check("SCardListReaders", rv) + buf = ctypes.create_string_buffer(size.value) + _check( + "SCardListReaders", + self._lib.SCardListReaders(self._h, None, buf, ctypes.byref(size)), + ) + raw = buf.raw[: size.value] + return [r.decode("utf-8", "replace") for r in raw.split(b"\0") if r] + + def pick_reader(self, want: str | None = None) -> str: + """Choose a reader. + + ``want`` is a case-insensitive substring. Without it, prefer a name + containing "PICC" (the ACR1252U also exposes its SAM slot as a second + reader, which must be skipped), else the first reader. + """ + readers = self.list_readers() + if not readers: + raise PCSCError("SCardListReaders", SCARD_E_NO_READERS_AVAILABLE) + if want: + for r in readers: + if want.lower() in r.lower(): + return r + raise PCSCError("pick_reader", SCARD_E_UNKNOWN_READER) + for r in readers: + if "PICC" in r.upper(): + return r + for r in readers: + if "SAM" not in r.upper(): + return r + return readers[0] + + def try_connect(self, reader: str) -> Card | None: + """One SCardConnect attempt. None when no card is on the reader.""" + h = SCARDHANDLE(0) + proto = DWORD(0) + rv = ( + self._lib.SCardConnect( + self._h, + reader.encode(), + SCARD_SHARE_EXCLUSIVE, + SCARD_PROTOCOL_ANY, + ctypes.byref(h), + ctypes.byref(proto), + ) + & 0xFFFFFFFF + ) + if rv in _TRANSIENT_ON_CONNECT: + return None + _check("SCardConnect", rv) + return Card(self._lib, h, proto.value, reader) + + def wait_for_card(self, reader: str, timeout: float | None = None, poll: float = 0.2) -> Card: + deadline = None if timeout is None else time.monotonic() + timeout + while True: + card = self.try_connect(reader) + if card is not None: + return card + if deadline is not None and time.monotonic() > deadline: + raise PCSCError("wait_for_card", SCARD_E_TIMEOUT) + time.sleep(poll) + + def wait_for_removal(self, reader: str, poll: float = 0.3) -> None: + while True: + card = self.try_connect(reader) + if card is None: + return + card.disconnect(SCARD_LEAVE_CARD) + time.sleep(poll) + + +@dataclass +class Card: + _lib: ctypes.CDLL + _h: SCARDHANDLE + protocol: int + reader: str + + def __enter__(self) -> Card: + return self + + def __exit__(self, *exc: object) -> None: + self.disconnect() + + @property + def atr(self) -> bytes: + atr = ctypes.create_string_buffer(36) + atr_len = DWORD(36) + name_len = DWORD(0) + state = DWORD(0) + proto = DWORD(0) + rv = self._lib.SCardStatus( + self._h, + None, + ctypes.byref(name_len), + ctypes.byref(state), + ctypes.byref(proto), + atr, + ctypes.byref(atr_len), + ) + _check("SCardStatus", rv) + return atr.raw[: atr_len.value] + + def transmit(self, apdu: bytes) -> bytes: + """Send a command APDU, return the full response including SW1 SW2.""" + send_pci = SCARD_IO_REQUEST(self.protocol, ctypes.sizeof(SCARD_IO_REQUEST)) + recv = ctypes.create_string_buffer(4096) + recv_len = DWORD(4096) + log.debug(">> %s", apdu.hex().upper()) + rv = self._lib.SCardTransmit( + self._h, + ctypes.byref(send_pci), + apdu, + len(apdu), + None, + recv, + ctypes.byref(recv_len), + ) + _check("SCardTransmit", rv) + resp = recv.raw[: recv_len.value] + log.debug("<< %s", resp.hex().upper()) + return resp + + def disconnect(self, disposition: int = SCARD_UNPOWER_CARD) -> None: + if self._h.value: + self._lib.SCardDisconnect(self._h, disposition) + self._h = SCARDHANDLE(0) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..e557015 --- /dev/null +++ b/uv.lock @@ -0,0 +1,288 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "boltcard-writer" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "cryptography" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.1" }, + { name = "cryptography", specifier = ">=42" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8" }, + { name = "ruff", specifier = ">=0.5" }, +] + +[[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/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { 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 = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +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.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/78/449cb84790bd5cc3823b2652ee405a4558856e5c4195aee3a16bf7b3eb5d/ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b", size = 4938814, upload-time = "2026-09-16T15:54:46.688Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/25/6071aabc530e9be7e2c195e8fe3f7aea2735405b6cf447212832d7811831/ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38", size = 10048966, upload-time = "2026-09-16T15:53:57.605Z" }, + { url = "https://files.pythonhosted.org/packages/54/98/07f90ecbc74dd5fb5764f11f2bc774d6a7cffef92d2ff5f5b4e9e23c754e/ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e", size = 10165498, upload-time = "2026-09-16T15:54:01.14Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/e6a712e3b47cad4a40600134105ed193cb773f618a42eb7ba323cb812cc0/ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170", size = 9830004, upload-time = "2026-09-16T15:54:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/23/f2/311a08776d75d81c7676e20b6b020ae63cbe881fcdc7a8dd64e6e18bdd93/ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659", size = 9986558, upload-time = "2026-09-16T15:54:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ed/37b6cb3d3ba8c73e68ae3eb1d502383beb5aa05a582bb7bb3a922f929f54/ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2", size = 9877332, upload-time = "2026-09-16T15:54:09.552Z" }, + { url = "https://files.pythonhosted.org/packages/22/cc/40873a8f36ad084cc540d55fcca7077264d5b13b24659e9180c176fb2b08/ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812", size = 10507125, upload-time = "2026-09-16T15:54:12.152Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e4/fc91a642b78ccbab6b9477720f3644ae7a10a9bcce69a934679cd64f62bc/ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b", size = 11336694, upload-time = "2026-09-16T15:54:15.489Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/bbd2a9a600a4e73dc3e7548a249c8d1671273464b55822c6fae50f602dff/ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f", size = 10774448, upload-time = "2026-09-16T15:54:18.16Z" }, + { url = "https://files.pythonhosted.org/packages/1a/41/d83af9879a7b6e8bf5fe16b1da0b134049d2f5d3afac12defb0897cb84bd/ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df", size = 10323796, upload-time = "2026-09-16T15:54:20.743Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/cefd07bfe914b84943ea769ade8d607bd22750b965d3228eefd7cebd15d0/ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e", size = 10514115, upload-time = "2026-09-16T15:54:23.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9d/76a2e26c79a23be6e6e3664c57bec9e9fc8de155cfb9e4b67ea91b64f9d7/ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f", size = 10072582, upload-time = "2026-09-16T15:54:26.185Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d4/f42edddb39668af1a559ceafa3823aedd65633a48dc9768e775485faa2c1/ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2", size = 9879644, upload-time = "2026-09-16T15:54:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/913e3195d95e0378786c6656945c865f534a3560e29139da4882aff630d1/ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9", size = 10231569, upload-time = "2026-09-16T15:54:32.036Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c4/8aa6ea0bdcedbd1bf87397e2fc4ed8406448ea5842f8660bc6e5f163039d/ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773", size = 10663666, upload-time = "2026-09-16T15:54:34.838Z" }, + { url = "https://files.pythonhosted.org/packages/3d/02/7f10ef4700bc223c30a3fdd10631a29830c45524b810a3c7ed947af64591/ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f", size = 10093472, upload-time = "2026-09-16T15:54:37.47Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5d/a509c07d714b6da88f2c518b4637cf6f1d46b074be8f0f1e5fb9ff5126fe/ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa", size = 10586899, upload-time = "2026-09-16T15:54:40.488Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a0/50787329e4f20bf9dc9f6230015d46ec69c51a97ace5bc202dae4755365d/ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a", size = 10386316, upload-time = "2026-09-16T15:54:43.332Z" }, +]