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 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-20 21:41:24 +02:00
commit 7d35d3e2c7
10 changed files with 1772 additions and 0 deletions

9
.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
.venv/
__pycache__/
*.pyc
*.egg-info/
dist/
build/
.pytest_cache/
.ruff_cache/
result

21
LICENSE Normal file
View file

@ -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.

40
pyproject.toml Normal file
View file

@ -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"]

7
shell.nix Normal file
View file

@ -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 <nixpkgs> { } }:
pkgs.mkShell {
packages = [ pkgs.uv pkgs.python3 pkgs.pcsclite pkgs.pcsc-tools ];
PCSCLITE_LIB = "${pkgs.pcsclite.lib}/lib/libpcsclite.so.1";
}

View file

@ -0,0 +1,3 @@
"""boltcard-writer: program Bolt Cards from a Linux PC with a USB NFC reader."""
__version__ = "0.1.0"

View file

@ -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=<otp>`` link, so a card written here is
indistinguishable from one written by the phone app:
1. write the NDEF URI ``<lnurlw_base>?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 <len> 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=<auth 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"")

252
src/boltcard_writer/cli.py Normal file
View file

@ -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://<lnbits>/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 <backup file>`.
"""
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()

View file

@ -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

357
src/boltcard_writer/pcsc.py Normal file
View file

@ -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)

288
uv.lock generated Normal file
View file

@ -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" },
]