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:
commit
7d35d3e2c7
10 changed files with 1772 additions and 0 deletions
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
result
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
40
pyproject.toml
Normal 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
7
shell.nix
Normal 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";
|
||||
}
|
||||
3
src/boltcard_writer/__init__.py
Normal file
3
src/boltcard_writer/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""boltcard-writer: program Bolt Cards from a Linux PC with a USB NFC reader."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
370
src/boltcard_writer/boltcard.py
Normal file
370
src/boltcard_writer/boltcard.py
Normal 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
252
src/boltcard_writer/cli.py
Normal 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()
|
||||
425
src/boltcard_writer/ntag424.py
Normal file
425
src/boltcard_writer/ntag424.py
Normal 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
357
src/boltcard_writer/pcsc.py
Normal 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
288
uv.lock
generated
Normal 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" },
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue