40 Commits

Author SHA1 Message Date
chaosrebel 577de21fbe Some translation work. Otherwise, good work by the mirrorer! A lifesaver. 2025-12-14 09:57:00 +00:00
Chr0n0s 823f2e32a0 add a simple deploy script for use 2025-12-14 15:37:33 +08:00
Chr0n0s 71ff27f099 add fork message in readme.md 2025-12-14 15:36:59 +08:00
Pol Henarejos 85639fc8e3 Add serial_number property.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-12-10 11:51:47 +01:00
Pol Henarejos d6f6ddbafd Version 1.3.2
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-30 19:48:13 +01:00
Pol Henarejos 4b41ebc243 Fix PHY read.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-30 19:47:59 +01:00
Pol Henarejos eb6d371354 Version 1.3.1
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-28 00:44:08 +01:00
Pol Henarejos f8b79e6313 Do not constraint setuptools
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-28 00:30:29 +01:00
Pol Henarejos b42064a73e Select applet before any command.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-28 00:30:20 +01:00
Pol Henarejos b085719f98 Version 1.3
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-26 19:22:23 +01:00
Pol Henarejos 1aae58a2f1 Add exceptions handling.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-26 19:21:43 +01:00
Pol Henarejos 19ffb414de Add reconnect method()
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-26 18:10:01 +01:00
Pol Henarejos cd5fd56a1d Version 1.2.1
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-26 13:23:27 +01:00
Pol Henarejos 7c2f454592 Add reboot command, to normal or bootsel mode.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-26 13:23:05 +01:00
Pol Henarejos a008e506c7 Version 1.2
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-26 12:50:27 +01:00
Pol Henarejos 79c1bcb178 Add logger and debug levels in PICOKEY_LOG environ.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-26 12:49:46 +01:00
Pol Henarejos e2d984adb0 Fix pyscard search when no context is available.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-26 11:54:11 +01:00
Pol Henarejos 872aca7c83 Add format check for enums.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-26 11:53:57 +01:00
Pol Henarejos 1658eb92b3 Version 1.1.7
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-26 00:05:45 +01:00
Pol Henarejos 94ef650f80 Fix monitor notifier.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-26 00:05:24 +01:00
Pol Henarejos 74fe60f2d4 Version 1.1.6
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-24 17:13:29 +01:00
Pol Henarejos e10b70df4e Add pyproject.toml
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-24 17:09:39 +01:00
Pol Henarejos d6d4489142 Add Observer to close the connection if needed.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-24 17:05:21 +01:00
Pol Henarejos ef80f8f271 Add methods.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-24 17:04:08 +01:00
Pol Henarejos 1fe46149b9 Version 1.1.5
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-23 20:44:32 +01:00
Pol Henarejos e7d92fba8a Add close() method.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-23 20:44:12 +01:00
Pol Henarejos 6b1b0f479f Version 1.1.4
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-23 19:00:38 +01:00
Pol Henarejos e5c3e9c824 More fixes.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-23 18:56:19 +01:00
Pol Henarejos a90e215aba Version 1.1.3
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-23 11:35:43 +01:00
Pol Henarejos 9be8b77983 Fix for Windows.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-23 11:35:20 +01:00
Pol Henarejos 4739f797de Version 1.1.2
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-20 23:48:25 +01:00
Pol Henarejos e0db8e1bab Add support for using only rescue device instead of pyscard.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-20 23:48:19 +01:00
Pol Henarejos 9dc893286a Version 1.1.1
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-20 23:06:16 +01:00
Pol Henarejos d5d126c01e pyscard 2.3.1 solves some bugs.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-20 23:04:20 +01:00
Pol Henarejos bfafb30da3 Version 1.1
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-20 22:48:55 +01:00
Pol Henarejos 2b292352a6 More fixes.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-20 20:11:54 +01:00
Pol Henarejos 0ff0ccd053 Add enum classes.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-18 01:04:06 +01:00
Pol Henarejos fd78641514 Add NamedIntEnum
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-18 01:03:53 +01:00
Pol Henarejos c59bf29a88 Add flash_info, secure_info and secure_boot.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-18 01:03:45 +01:00
Pol Henarejos abd1c7945f Update python matrix.
Signed-off-by: Pol Henarejos <pol.henarejos@cttc.es>
2025-11-12 17:44:41 +01:00
15 changed files with 989 additions and 71 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.9", "3.10"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v3
+3
View File
@@ -1,6 +1,9 @@
# pypicokey
PicoKey tools for Python
Forked From https://github.com/polhenarejos/pypicokey,
If fork is unproper please tell me and i'd delete it.
## Introduction
PicoKey firmware allows to convert a Raspberry Pico into a Hardware Security Module (HSM), FIDO2 device or OpenPGP card, to store private and secret keys, perform signing and ciphering operations, without exposing the key.
+323
View File
@@ -0,0 +1,323 @@
#!/usr/bin/env python3
"""Interactive commissioning helper for PicoKey
This script collects commissioning options (VID:PID, LED, options,
product name, curves, etc.) and writes them to a JSON file
(`commission_config.json`). It can optionally attempt to apply the
configuration when `--apply` is passed; the apply action is a safe
stub and will ask for confirmation before performing any hardware
changes.
Usage:
python configure.py # interactive
python configure.py --out mycfg.json
python configure.py --yes --out mycfg.json
python configure.py --apply # will prompt before touching device
"""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
from typing import Optional
CFG_OUT = "commission_config.json"
VIDPID_RE = re.compile(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{4}$")
def prompt(text: str, default: Optional[str] = None) -> Optional[str]:
if default is None:
val = input(f"{text} ")
else:
val = input(f"{text} [Default: {default}] ")
if val.strip() == "":
return None
return val.strip()
def prompt_int(text: str, default: Optional[int] = None, allow_empty=True) -> Optional[int]:
while True:
s = prompt(text, str(default) if default is not None else None)
if s is None:
return None if allow_empty else default
try:
return int(s)
except ValueError:
print("Please enter a number, or press enter to leave blank.")
def prompt_bool(text: str, default: Optional[bool] = None) -> Optional[bool]:
while True:
defstr = None if default is None else ("y" if default else "n")
s = prompt(text + " (y/n)", defstr)
if s is None:
return None
if s.lower() in ("y", "yes", "t", "1"):
return True
if s.lower() in ("n", "no", "f", "0"):
return False
print("Please enter either "Y" or "N", or leave blank to keep the option unchanged.")
def choose(text: str, choices: list[str], default: Optional[int] = None) -> Optional[str]:
print(text)
for i, c in enumerate(choices, start=1):
print(f" {i}. {c}")
sel = prompt_int("Enter number, or leave blank to keep current setting:", default=None)
if sel is None:
return None
if 1 <= sel <= len(choices):
return choices[sel - 1]
print("Sorry, this input's not recognised. Either enter a number, or leave blank to keep the current setting:")
return None
def validate_vidpid(v: str) -> bool:
return bool(VIDPID_RE.match(v))
def interactive_build() -> dict:
print("Welcome to the Python PicoKey interactive Comissioner,")
# Vendor / VID:PID
VENDORS = {
"Nitrokey HSM": {"vid": "20a0", "pid": "4230"},
"Nitrokey FIDO2": {"vid": "20a0", "pid": "42b1"},
"Nitrokey Pro": {"vid": "20a0", "pid": "4108"},
"Nitrokey 3": {"vid": "20a0", "pid": "42b2"},
"Nitrokey Start": {"vid": "20a0", "pid": "4211"},
"Yubikey 4/5": {"vid": "1050", "pid": "0407"},
"Yubikey NEO": {"vid": "1050", "pid": "0116"},
"Yubico YubiHSM": {"vid": "1050", "pid": "0030"},
"FSIJ Gnuk": {"vid": "234b", "pid": "0000"},
"GnuPG e.V.": {"vid": "1209", "pid": "2440"},
}
vendor_choices = list(VENDORS.keys()) + ["Custom VID:PID"]
vendor_choice = choose("Select a known vendor...", vendor_choices, None)
vidpid = None
if vendor_choice == "Custom VID:PID":
while True:
v = prompt("Type VID:PID in hex form (0123:abcd):")
if v is None:
vidpid = None
break
if validate_vidpid(v):
vidpid = v.lower()
break
print("Wrong format. It should be in hexadecimal format. An example is 0123:abcd.")
elif vendor_choice in VENDORS:
e = VENDORS[vendor_choice]
vidpid = f"{e['vid']}:{e['pid']}"
presence = prompt_int("Presence Button Timeout (seconds, 0=disabled):", default=None)
led_brightness = prompt_int("LED brightness (0=off):", default=None)
# Options
print("Options: 留空保持当前")
led_dimmable = prompt_bool("LED dimmable?", default=None)
initialize = prompt_bool("Initialize device (will reset some state)?", default=None)
secure_boot = prompt_bool("Enable Secure Boot? (WebUSB required)", default=None)
secure_lock = prompt_bool("Enable Secure Lock? (WebUSB required)", default=None)
power_cycle = prompt_bool("Power Cycle on Reset? (Pico FIDO only)", default=None)
led_steady = prompt_bool("LED steady (always on)?", default=None)
secp256k1 = prompt_bool("Enable secp256k1 curve? (Android may not support)", default=None)
led_gpio = prompt_int("LED GPIO pin (number):", default=None)
drivers = ["PICO", "PIMORONI", "WS2812", "CYW43", "NEOPIXEL", "NONE"]
led_driver = choose("Select a LED driver:", drivers, None)
product_name = prompt("Product Name (max 14 chars):")
if product_name is not None and len(product_name) > 14:
print("Product name 超过 14 字符,将被截断。")
product_name = product_name[:14]
cfg = {
"vendor_choice": vendor_choice,
"vidpid": vidpid,
"presence_timeout": presence,
"led_brightness": led_brightness,
"options": {
"led_dimmable": led_dimmable,
"initialize": initialize,
"secure_boot": secure_boot,
"secure_lock": secure_lock,
"power_cycle_on_reset": power_cycle,
"led_steady": led_steady,
"secp256k1": secp256k1,
},
"led_gpio": led_gpio,
"led_driver": led_driver,
"product_name": product_name,
}
return cfg
def build_phy_bytes(cfg: dict) -> bytes:
# mirror getPhyData() TLV construction
PHY_VID = 0x0
PHY_LED_GPIO = 0x4
PHY_LED_BTNESS = 0x5
PHY_OPTS = 0x6
PHY_OPT_WCID = 0x1
PHY_OPT_DIMM = 0x2
PHY_OPT_DISABLE_POWER_RESET = 0x4
PHY_OPT_LED_STEADY = 0x8
PHY_UP_BUTTON = 0x8
PHY_USB_PRODUCT = 0x9
PHY_ENABLED_CURVES = 0xA
PHY_LED_DRIVER = 0xC
PHY_CURVE_SECP256K1 = 0x8
PHY_LED_DRIVER_SINGLE = 0x1
PHY_LED_DRIVER_WS2812 = 0x3
b = bytearray()
# VID/PID
vidpid = cfg.get("vidpid")
if vidpid:
try:
vid_str, pid_str = vidpid.split(":")
vid = int(vid_str, 16)
pid = int(pid_str, 16)
b += bytes([PHY_VID, 4, (vid >> 8) & 0xFF, vid & 0xFF, (pid >> 8) & 0xFF, pid & 0xFF])
except Exception:
pass
# LED GPIO
lg = cfg.get("led_gpio")
if lg is not None:
b += bytes([PHY_LED_GPIO, 1, int(lg) & 0xFF])
# LED brightness
lb = cfg.get("led_brightness")
if lb is None:
lb = 0
b += bytes([PHY_LED_BTNESS, 1, int(lb) & 0xFF])
# opts
opts = 0
opts_map = cfg.get("options", {})
if opts_map.get("led_dimmable"):
opts |= PHY_OPT_DIMM
if not opts_map.get("power_cycle_on_reset"):
opts |= PHY_OPT_DISABLE_POWER_RESET
if opts_map.get("led_steady"):
opts |= PHY_OPT_LED_STEADY
b += bytes([PHY_OPTS, 2, (opts >> 8) & 0xFF, opts & 0xFF])
# Presence / Up button timeout
btn = cfg.get("presence_timeout")
if btn is None:
btn = 0
b += bytes([PHY_UP_BUTTON, 1, int(btn) & 0xFF])
# curves
curves = 0
if opts_map.get("secp256k1"):
curves |= PHY_CURVE_SECP256K1
b += bytes([PHY_ENABLED_CURVES, 4, (curves >> 24) & 0xFF, (curves >> 16) & 0xFF, (curves >> 8) & 0xFF, curves & 0xFF])
# USB product string
pn = cfg.get("product_name")
if pn:
s = pn.encode("ascii", "ignore")
b += bytes([PHY_USB_PRODUCT, len(s) + 1]) + s + b"\x00"
# led driver
ld = cfg.get("led_driver")
leddrv = 0
if ld is not None:
if ld.upper().startswith("WS"):
leddrv = PHY_LED_DRIVER_WS2812
elif ld.upper().startswith("PICO"):
leddrv = PHY_LED_DRIVER_SINGLE
elif ld.upper() == "NONE":
leddrv = 0xFF
b += bytes([PHY_LED_DRIVER, 1, leddrv & 0xFF])
return bytes(b)
def save_config(cfg: dict, out: Path):
out.write_text(json.dumps(cfg, ensure_ascii=False, indent=2))
print(f"配置已保存到 {out}")
def apply_stub(cfg: dict) -> None:
print("\n-- APPLY (STUB) --")
print("脚本将在此处尝试应用配置到本机已连接的 PicoKey(本地方式)。")
print("配置摘要:")
print(json.dumps(cfg, ensure_ascii=False, indent=2))
confirm = input("确认要尝试直接应用配置到本机已连接设备吗?(y/N) ")
if confirm.lower() != "y":
print("取消应用。配置已保存到文件,可使用本地 picokey 库或其他工具手动应用。")
return
# 尝试使用本地 picokey 库把 PHY bytes 写入设备
try:
import picokey # type: ignore
except Exception as e:
print("未检测到可用的 picokey 库:", e)
print("请确保已安装 pypicokey 并在正确的 Python 环境下运行此脚本。")
return
def apply_local(cfg: dict) -> None:
try:
pk = picokey.PicoKey()
except Exception as e:
print("无法连接到 PicoKey 设备:", e)
return
try:
phy = build_phy_bytes(cfg)
if not phy:
print("未生成任何 PHY 字节,跳过写入。")
return
print(f"准备写入 {len(phy)} 字节到设备...")
try:
# PicoKey.phy expects Optional[list[int]] for data
pk.phy(list(phy))
print("已成功写入 PHY 配置到设备。")
except Exception as e:
print("写入设备时发生错误:", e)
finally:
try:
pk.close()
except Exception:
pass
apply_local(cfg)
def main() -> None:
parser = argparse.ArgumentParser(description="PicoKey commissioning configurator")
parser.add_argument("--out", "-o", help="输出 JSON 文件路径", default=CFG_OUT)
parser.add_argument("--apply", action="store_true", help="交互式确认后尝试应用配置到设备(stub)")
parser.add_argument("--yes", "-y", action="store_true", help="自动确认所有提示(留空将被视为保留当前值)")
args = parser.parse_args()
cfg = interactive_build()
outpath = Path(args.out)
if outpath.exists():
if not args.yes:
overwrite = input(f"{outpath} 已存在,是否覆盖?(y/N) ")
if overwrite.lower() != "y":
print("取消,未保存文件。")
return
save_config(cfg, outpath)
if args.apply:
apply_stub(cfg)
if __name__ == "__main__":
main()
+7 -13
View File
@@ -18,10 +18,9 @@
*/
"""
from enum import IntEnum
from .core import NamedIntEnum
class PhyTag(IntEnum):
class PhyTag(NamedIntEnum):
VIDPID = 0x0
LED_GPIO = 0x4
LED_BTNESS = 0x5
@@ -32,15 +31,13 @@ class PhyTag(IntEnum):
ENABLED_USB_ITF = 0xB
LED_DRIVER = 0xC
class PhyOpt(IntEnum):
class PhyOpt(NamedIntEnum):
WCID = 0x1
DIMM = 0x2
DISABLE_POWER_RESET = 0x4
LED_STEADY = 0x8
class PhyCurve(IntEnum):
class PhyCurve(NamedIntEnum):
SECP256R1 = 0x1
SECP384R1 = 0x2
SECP521R1 = 0x4
@@ -53,15 +50,13 @@ class PhyCurve(IntEnum):
CURVE25519 = 0x200
CURVE448 = 0x400
class PhyUsbItf(IntEnum):
class PhyUsbItf(NamedIntEnum):
CCID = 0x1
WCID = 0x2
HID = 0x4
KB = 0x8
class PhyLedDriver(IntEnum):
class PhyLedDriver(NamedIntEnum):
PICO = 0x1
PIMORONI = 0x2
WS2812 = 0x3
@@ -69,7 +64,6 @@ class PhyLedDriver(IntEnum):
NEOPIXEL = 0x5
NONE = 0xFF
class PhyData:
def __init__(self, **kwargs):
self.vidpid = kwargs.get("vidpid")
@@ -121,7 +115,7 @@ class PhyData:
b = bytearray()
if self.vidpid:
b += bytes([PhyTag.VIDPID, 4])
b += bytes([self.vidpid[1], self.vidpid[0], self.vidpid[3], self.vidpid[2]])
b += bytes(self.vidpid[:4])
if self.led_gpio is not None:
b += bytes([PhyTag.LED_GPIO, 1, self.led_gpio & 0xFF])
if self.led_brightness is not None:
+275 -47
View File
@@ -17,75 +17,241 @@
*/
"""
import sys
from typing import Optional
import threading
from .APDU import APDUResponse
from .SecureChannel import SecureChannel
try:
from smartcard.CardType import AnyCardType
from smartcard.CardRequest import CardRequest
from smartcard.Exceptions import CardRequestTimeoutException, CardConnectionException
except ModuleNotFoundError:
print('ERROR: smartcard module not found! Install pyscard package.\nTry with `pip install pyscard`')
sys.exit(-1)
from .RescuePicoKey import RescuePicoKey
from .RescueMonitor import RescueMonitor, RescueMonitorObserver
from .PhyData import PhyData
import enum
from .core import NamedIntEnum
from .core.exceptions import PicoKeyNotFoundError, PicoKeyInvalidStateError
import usb.core
from .core.log import get_logger
class Platform(enum.IntEnum):
logger = get_logger("PicoKey")
class Platform(NamedIntEnum):
RP2040 = 0
RP2350 = 1
ESP32 = 2
EMULATION = 3
class Product(enum.IntEnum):
class Product(NamedIntEnum):
UNKNOWN = 0
HSM = 1
FIDO = 2
OPENPGP = 3
class ConnectionType(NamedIntEnum):
UNKNOWN = 0
SMARTCARD = 1
RESCUE = 2
class ConnectTimeout(Exception):
pass
def connect_with_timeout(connection, timeout=2.0):
result = {}
def worker():
try:
connection.connect()
result["ok"] = True
except Exception as e:
result["error"] = e
t = threading.Thread(target=worker)
t.daemon = True
t.start()
t.join(timeout)
if t.is_alive():
raise ConnectTimeout("connection.connect() timed out")
if "error" in result:
raise result["error"]
return True
class PicoKey:
def __init__(self, slot=-1):
def __init__(self, slot=-1, force_rescue=False):
logger.debug("Initializing PicoKey...")
self.__apdu = []
self.__connection_type = ConnectionType.UNKNOWN
self.__monitor = None
self.__observer = None
self.__sc = None
cardtype = AnyCardType()
try:
# request card insertion
readers = None
if (slot >= 0):
readers = CardRequest().getReaders()
if (slot >= len(readers)):
raise Exception('slot out of range')
readers = [readers[slot]]
cardrequest = CardRequest(timeout=1, cardType=cardtype, readers=readers)
self.__card = cardrequest.waitforcard().connection
self.__card = None
# connect to the card and perform a few transmits
self.__card.connect()
if (not force_rescue):
from smartcard.System import readers
import smartcard.Exceptions
from smartcard.CardMonitoring import CardMonitor, CardObserver
logger.debug("Searching for smartcard readers...")
class PicoCardObserver(CardObserver):
def __init__(self, device):
self.__device = device
except CardRequestTimeoutException:
def update(self, observable, actions):
(added, removed) = actions
if added:
pass
if removed:
self.__device.close()
def reader_has_card(reader):
try:
connection = reader.createConnection()
connect_with_timeout(connection, timeout=1.0)
return connection
except smartcard.Exceptions.NoCardException:
return None
return None
logger.debug("Checking available smartcard readers...")
try:
rdrs = readers()
except Exception as e:
logger.error("Error accessing smartcard readers: " + str(e))
rdrs = []
if len(rdrs) > 0:
if (slot >= 0 and slot >= len(rdrs)):
logger.error("Slot number out of range")
raise Exception('Slot number out of range')
if (slot >= 0 and slot < len(rdrs)):
logger.debug(f"Checking reader slot {slot}")
reader = rdrs[slot]
connection = reader_has_card(reader)
if (connection is None):
logger.error(f"No card in reader slot {slot}")
raise Exception(f'No card in reader slot {slot}')
self.__card = connection
else:
for i, reader in enumerate(rdrs):
reader = rdrs[i]
connection = reader_has_card(reader)
if (connection is None):
continue
logger.debug(f"Card found in reader slot {i}")
self.__card = connection
self.__connection_type = ConnectionType.SMARTCARD
logger.debug("Setting up card monitor...")
self.__monitor = CardMonitor()
logger.debug("Creating card observer...")
self.__observer = PicoCardObserver(self)
logger.debug("Adding observer to monitor...")
self.__monitor.addObserver(self.__observer)
logger.debug("Observer added to monitor")
break
if (self.__card is None):
logger.debug("Attempting to connect in rescue mode...")
class PicoRescueObserver(RescueMonitorObserver):
def __init__(self, device):
self.__device = device
def update(self, actions: tuple[Optional[usb.core.Device], Optional[usb.core.Device]]):
(connected, disconnected) = actions
if connected:
logger.debug("Observer: Rescue device connected")
pass
if disconnected:
logger.debug("Observer: Rescue device disconnected, closing...")
self.__device.close()
try:
self.__card = RescuePicoKey()
logger.debug("Rescue mode card initialized")
self.__connection_type = ConnectionType.RESCUE
logger.debug("Setting up rescue monitor...")
self.__observer = PicoRescueObserver(self)
logger.debug("Creating rescue monitor...")
self.__monitor = RescueMonitor(device=self.__card, cls_callback=self.__observer)
except Exception:
raise Exception('time-out: no card inserted')
resp, sw1, sw2 = self.select_applet(rescue=True)
logger.error("No PicoKey device detected")
raise PicoKeyNotFoundError('time-out: no card inserted')
try:
resp, sw1, sw2 = self.select_applet(rescue=True)
logger.debug("Selecting applet in rescue mode...")
resp, sw1, sw2 = self.select_applet()
logger.debug(f"Applet selected with response code: 0x{sw1:02X}{sw2:02X}")
if (sw1 == 0x90 and sw2 == 0x00):
self.platform = Platform(resp[0])
self.product = Product(resp[1])
self.version = (resp[2], resp[3])
if (len(resp) >= 12):
self.serial_number = int.from_bytes(resp[4:12], 'big')
else:
self.serial_number = 0
logger.debug(f"Device platform: {self.platform.name}, product: {self.product.name}, version: {self.version}, serial number: {self.serial_number:016X}")
else:
logger.error("Unexpected response code during applet selection")
self.platform = Platform(Platform.RP2040)
self.product = Product(Product.UNKNOWN)
self.version = (0, 0)
self.serial_number = 0
except APDUResponse:
logger.error("APDU response error during applet selection")
self.platform = Platform(Platform.RP2040)
self.product = Product(Product.UNKNOWN)
self.version = (0, 0)
self.serial_number = 0
def transmit(self, apdu):
response, sw1, sw2 = self.__card.transmit(apdu)
return response, sw1, sw2
@property
def device(self):
return self.__card
def send(self, command, cla=0x00, p1=0x00, p2=0x00, ne=None, data=None, codes=[]):
def has_device(self):
return self.__card is not None
@property
def connection_type(self):
return self.__connection_type
def close(self):
logger.debug("Closing device...")
if (not self.__card):
logger.debug("No device to close")
return
if isinstance(self.__card, RescuePicoKey):
logger.debug("Stopping rescue monitor...")
self.__monitor.stop()
self.__monitor = None
self.__observer = None
self.__card.close()
else:
logger.debug("Removing card monitor observer...")
if (self.__monitor and self.__observer):
self.__monitor.deleteObserver(self.__observer)
self.__observer = None
self.__monitor = None
logger.debug("Disconnecting and releasing card...")
try:
self.__card.disconnect()
logger.debug("Card disconnected")
self.__card.release()
except Exception as e:
logger.error("Error during card disconnect/release: " + str(e))
self.__card = None
def transmit(self, apdu: list[int]):
if (not self.__card):
logger.error("No device connected")
raise PicoKeyNotFoundError('No device connected')
try:
response, sw1, sw2 = self.__card.transmit(apdu)
return response, sw1, sw2
except Exception as e:
logger.error("Transmission error: " + str(e))
raise PicoKeyInvalidStateError("Transmission error: " + str(e))
def send(self, command: int, cla: int = 0x00, p1: int =0x00, p2: int=0x00, ne : Optional[int] = None, data : Optional[list[int]] = None, codes : list[int] = []):
logger.debug(f"Sending command {hex(command)} with cla={hex(cla)}, p1={hex(p1)}, p2={hex(p2)}, ne={ne}")
if (not self.__card):
logger.error("No device connected")
raise PicoKeyNotFoundError('No device connected')
lc = []
dataf = []
if (data):
@@ -104,14 +270,27 @@ class PicoKey:
apdu = apdu + [p1, p2] + lc + dataf + le
self.__apdu = apdu
logger.trace(f"APDU -> {' '.join([f'{x:02X}' for x in apdu])}")
if (self.__sc):
apdu = self.__sc.wrap_apdu(apdu)
logger.trace(f"Wrapped APDU -> {' '.join([f'{x:02X}' for x in apdu])}")
try:
response, sw1, sw2 = self.__card.transmit(apdu)
except CardConnectionException:
self.__card.reconnect()
response, sw1, sw2 = self.__card.transmit(apdu)
except Exception:
logger.debug("Reconnecting card after transmit failure")
try:
self.__card.reconnect()
except Exception as e:
logger.error("Reconnection failed: " + str(e))
self.close()
raise PicoKeyNotFoundError('Reconnection failed: ' + str(e))
try:
response, sw1, sw2 = self.__card.transmit(apdu)
except Exception as e:
logger.error("APDU transmission error after reconnect: " + str(e))
raise PicoKeyInvalidStateError("APDU transmission error after reconnect: " + str(e))
code = (sw1<<8|sw2)
if (sw1 != 0x90):
@@ -132,8 +311,10 @@ class PicoKey:
code = (sw1<<8|sw2)
if (code not in codes and code != 0x9000):
raise APDUResponse(sw1, sw2)
logger.trace(f"Response APDU <- {' '.join([f'{x:02X}' for x in response])}, SW1={sw1:02X}, SW2={sw2:02X}")
if (self.__sc):
response, code = self.__sc.unwrap_rapdu(response)
logger.trace(f"Unwrapped RAPDU <- {' '.join([f'{x:02X}' for x in response])}, Code={code:04X}")
if (code not in codes and code != 0x9000):
raise APDUResponse(code >> 8, code & 0xff)
return bytes(response), code
@@ -145,27 +326,74 @@ class PicoKey:
try:
response, sw1, sw2 = self.__card.transmit(apdu)
except CardConnectionException:
except Exception:
logger.debug("Reconnecting card after transmit failure")
self.__card.reconnect()
response, sw1, sw2 = self.__card.transmit(apdu)
return bytes(response), sw1, sw2
def open_secure_channel(self, shared, nonce, token, pbkeyBytes):
def open_secure_channel(self, shared: bytes, nonce: bytes, token: bytes, pbkeyBytes: bytes):
logger.debug("Opening secure channel")
sc = SecureChannel(shared=shared, nonce=nonce)
res = sc.verify_token(token, pbkeyBytes)
if (not res):
raise Exception('Secure Channel token verification failed')
self.__sc = sc
def select_applet(self, rescue=False):
if (rescue):
return self.transmit([0x00, 0xA4, 0x04, 0x04, 0x08, 0xA0, 0x58, 0x3F, 0xC1, 0x9B, 0x7E, 0x4F, 0x21, 0x00])
return self.transmit([0x00, 0xA4, 0x04, 0x00, 0x0B, 0xE8, 0x2B, 0x06, 0x01, 0x04, 0x01, 0x81, 0xC3, 0x1F, 0x02, 0x01, 0x00])
def select_applet(self):
logger.debug("Selecting rescue applet")
return self.transmit([0x00, 0xA4, 0x04, 0x04, 0x08, 0xA0, 0x58, 0x3F, 0xC1, 0x9B, 0x7E, 0x4F, 0x21, 0x00])
def phy(self, data=None):
def phy(self, data : Optional[list[int]] = None):
if (data is None):
resp, sw = self.send(0x1E, cla=0x80, p1=0x01, ne=256)
return PhyData.parse(resp)
try:
self.select_applet()
resp, sw = self.send(0x1E, cla=0x80, p1=0x01, ne=256)
return PhyData.parse(resp)
except Exception:
pass
return None
else:
self.send(0x1C, cla=0x80, p1=0x01, data=data)
def flash_info(self):
logger.debug("Retrieving flash info")
try:
self.select_applet()
resp, sw = self.send(0x1E, cla=0x80, p1=0x02)
free = int.from_bytes(resp[0:4], 'big')
used = int.from_bytes(resp[4:8], 'big')
total = int.from_bytes(resp[8:12], 'big')
nfiles = int.from_bytes(resp[12:16], 'big')
size = int.from_bytes(resp[16:20], 'big')
except Exception:
free = used = total = nfiles = size = 0
return {
'free': free,
'used': used,
'total': total,
'nfiles': nfiles,
'size': size
}
def secure_info(self):
logger.debug("Retrieving secure boot info")
self.select_applet()
resp, sw = self.send(0x1E, cla=0x80, p1=0x03)
return {
'enabled': resp[0] != 0,
'locked': resp[1] != 0,
'boot_key': resp[2]
}
def secure_boot(self, bootkey_index: int = 0, lock: bool = False):
logger.debug(f"Setting secure boot: bootkey_index={bootkey_index}, lock={lock}")
self.select_applet()
data = bytes([bootkey_index & 0xFF, 1 if lock else 0])
self.send(0x1C, cla=0x80, p1=0x02, data=data)
def reboot(self, bootsel: bool = False):
logger.debug("Rebooting device into BOOTSEL mode" if bootsel else "Rebooting device into normal mode")
self.select_applet()
self.send(0x1F, cla=0x80, p1=0x01 if bootsel else 0x00)
+82
View File
@@ -0,0 +1,82 @@
"""
/*
* This file is part of the pypicokey distribution (https://github.com/polhenarejos/pypicokey).
* Copyright (c) 2025 Pol Henarejos.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
"""
from typing import Optional
import usb.core
import threading
import time
from .RescuePicoKey import RescuePicoKey
class RescueMonitorObserver:
def __init__(self):
pass
def notifyObservers(self, actions: tuple[Optional[usb.core.Device], Optional[usb.core.Device]]):
func = getattr(self, "update", None)
if callable(func):
func(actions)
def on_connect(self, device: Optional[usb.core.Device]):
self.notifyObservers((device, None))
def on_disconnect(self, device: Optional[usb.core.Device]):
self.notifyObservers((None, device))
class RescueMonitor:
def __init__(self, device: RescuePicoKey, cls_callback: RescueMonitorObserver, interval=0.5):
self._dev = device
self._cls_callback = cls_callback
self.interval = interval
self._running = False
self._device_present = False
self._thread = None
self.start()
def start(self):
if self._running:
return
self._running = True
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
def stop(self):
self._running = False
#if self._thread:
# self._thread.join()
def _run(self):
while self._running:
if (self._dev is None) or (self._dev.device is None):
time.sleep(self.interval)
continue
dev = usb.core.find(idVendor=self._dev.device.idVendor, idProduct=self._dev.device.idProduct)
if dev and not self._device_present:
# Device connected
self._device_present = True
if self._cls_callback:
self._cls_callback.on_connect(dev)
if not dev and self._device_present:
# Device disconnected
self._device_present = False
if self._cls_callback:
self._cls_callback.on_disconnect(self._dev.device)
time.sleep(self.interval)
+109 -7
View File
@@ -17,13 +17,34 @@
*/
"""
import time
import usb.core
import usb.util
import libusb_package
import usb.backend.libusb1
from .ICCD import ICCD
from .core.log import get_logger
logger = get_logger("RescuePicoKey")
class RescuePicoKeyError(Exception):
pass
class RescuePicoKeyNotFoundError(RescuePicoKeyError):
pass
class RescuePicoKeyInvalidStateError(RescuePicoKeyError):
pass
class RescuePicoKey:
def __init__(self):
logger.debug("Initializing RescuePicoKey...")
self.__dev = None
self.__in = None
self.__out = None
self.__int = None
self.__active = None
class find_class(object):
def __init__(self, class_):
@@ -37,11 +58,20 @@ class RescuePicoKey:
return True
return False
devs = usb.core.find(find_all=True, custom_match=find_class(0x0B))
logger.debug("Searching for USB device...")
backend = usb.backend.libusb1.get_backend(find_library=libusb_package.find_library)
try:
devs = usb.core.find(find_all=True, custom_match=find_class(0x0B), backend=backend)
except Exception as e:
logger.error("Exception during usb.core.find: %s", e)
devs = []
found = False
for dev in devs:
if (dev.manufacturer == 'Pol Henarejos'):
logger.debug("Found device")
dev.set_configuration()
logger.debug("Device configuration set")
logger.debug("Getting active configuration...")
cfg = dev.get_active_configuration()
for intf in cfg:
if (intf.bInterfaceClass == 0xFF):
@@ -58,36 +88,95 @@ class RescuePicoKey:
self.__in = epin.bEndpointAddress
self.__out = epout[0].bEndpointAddress
self.__int = epint.bEndpointAddress if epint else None
logger.debug(f"Endpoints - IN: 0x{self.__in:02X}, OUT: 0x{self.__out:02X}, INT: {self.__int}")
self.__iccd = ICCD(self)
logger.debug("ICCD interface initialized")
self.__active = None
logger.debug("Powering off device")
self.powerOff()
logger.debug("Device powered off")
found = True
break
if (not found):
raise Exception('Not found any Pico Key device')
logger.error("No suitable device found")
raise RescuePicoKeyNotFoundError('Not found any Pico Key device')
@property
def device(self):
return self.__dev
@property
def serial_number(self) -> str:
return usb.util.get_string(self.__dev, self.__dev.iSerialNumber)
def close(self):
logger.debug("Closing device")
if self.__dev:
logger.debug("Disposing USB resources")
usb.util.dispose_resources(self.__dev)
logger.debug("Device closed")
self.__dev = None
def has_card(self):
return self.__dev is not None
def __exit__(self, exc_type, exc, tb):
self.close()
def __del__(self):
self.close()
def __str__(self):
return str(self.__dev)
def read(self, timeout=2000):
ret = self.__dev.read(self.__in, 4096, timeout)
return ret
logger.debug("Reading data from device")
try:
ret = self.__dev.read(self.__in, 4096, timeout)
logger.trace(f"Data read from device: {' '.join([f'{x:02X}' for x in ret])}")
logger.debug("Read data from device")
return ret
except Exception as e:
logger.error("USB read error: " + str(e))
raise RescuePicoKeyInvalidStateError("USB read error: " + str(e))
def write(self, data, timeout=2000):
assert(self.__dev.write(self.__out, data, timeout) == len(data))
logger.debug("Writing data to device")
logger.trace(f"Data to write to device: {' '.join([f'{x:02X}' for x in data])}")
try:
assert(self.__dev.write(self.__out, data, timeout) == len(data))
logger.debug("Wrote data to device")
except Exception as e:
logger.error("USB write error: " + str(e))
raise RescuePicoKeyInvalidStateError("USB write error: " + str(e))
def exchange(self, data, timeout=2000):
self.write(data=data, timeout=timeout)
return self.read(timeout=timeout)
logger.debug("Exchanging data with device")
try:
self.write(data=data, timeout=timeout)
except Exception as e:
logger.error("USB write error: " + str(e))
raise RescuePicoKeyInvalidStateError("USB write error: " + str(e))
try:
ret = self.read(timeout=timeout)
except Exception as e:
logger.error("USB read error: " + str(e))
raise RescuePicoKeyInvalidStateError("USB read error: " + str(e))
return ret
def powerOn(self):
logger.debug("Powering on device")
if (not self.__active):
self.__active = True
logger.debug("Device powered on")
return self.__iccd.IccPowerOn()
def powerOff(self):
logger.debug("Powering off device")
if (self.__active or self.__active is None):
logger.debug("Device powered off")
self.__iccd.IccPowerOff()
logger.debug("ICCD powered off")
self.__active = False
def transmit(self, apdu):
@@ -95,3 +184,16 @@ class RescuePicoKey:
self.powerOn()
rapdu = self.__iccd.SendApdu(apdu=apdu)
return rapdu[:-2], rapdu[-2], rapdu[-1]
def reconnect(self):
logger.debug("Reconnecting to device")
self.close()
time.sleep(1)
try:
self.__init__()
except Exception as e:
logger.error("Reconnection failed: %s", e)
self.close()
raise e
logger.debug("Reconnected to device")
return self
+2
View File
@@ -5,3 +5,5 @@ from .APDU import APDUResponse
from .SWCodes import SWCodes
from .RescuePicoKey import RescuePicoKey
from .PhyData import PhyData, PhyCurve, PhyUsbItf, PhyLedDriver, PhyOpt
from .core import enums
from .core.exceptions import PicoKeyError, PicoKeyNotFoundError, PicoKeyInvalidStateError
+1 -1
View File
@@ -18,4 +18,4 @@
*/
"""
__version__ = "1.0"
__version__ = "1.3.2"
+2
View File
@@ -0,0 +1,2 @@
from .enums import *
from .exceptions import *
+44
View File
@@ -0,0 +1,44 @@
"""
/*
* This file is part of the pypicokey distribution (https://github.com/polhenarejos/pypicokey).
* Copyright (c) 2025 Pol Henarejos.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
"""
import enum
from typing import Union
class NamedIntEnum(enum.IntEnum):
def __str__(self):
return self.name
def __format__(self, fmt):
if any(c in fmt for c in "xXod"):
return format(self.value, fmt)
return self.name
@classmethod
def from_string(cls, value: Union[str, int]) -> "NamedIntEnum":
if not value:
return cls.UNKNOWN
value = value.strip().lower()
for member in cls:
if member.value == value or member.name.lower() == value:
return member
return cls.UNKNOWN
+27
View File
@@ -0,0 +1,27 @@
"""
/*
* This file is part of the pypicokey distribution (https://github.com/polhenarejos/pypicokey).
* Copyright (c) 2025 Pol Henarejos.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
"""
class PicoKeyError(Exception):
pass
class PicoKeyNotFoundError(PicoKeyError):
pass
class PicoKeyInvalidStateError(PicoKeyError):
pass
+58
View File
@@ -0,0 +1,58 @@
"""
/*
* This file is part of the pypicokey distribution (https://github.com/polhenarejos/pypicokey).
* Copyright (c) 2025 Pol Henarejos.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, version 3.
*
* This program is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
"""
import os
import logging
TRACE_LEVEL = 5
logging.addLevelName(TRACE_LEVEL, "TRACE")
def trace(self, message, *args, **kwargs):
if self.isEnabledFor(TRACE_LEVEL):
self._log(TRACE_LEVEL, message, args, **kwargs)
# Afegeix logger.trace()
logging.Logger.trace = trace
def get_logger(name: str):
env_level = os.getenv("PICOKEY_LOG", "CRITICAL").upper()
valid_levels = {
"TRACE": TRACE_LEVEL,
"DEBUG": logging.DEBUG,
"INFO": logging.INFO,
"WARNING": logging.WARNING,
"ERROR": logging.ERROR,
"CRITICAL": logging.CRITICAL,
}
level = valid_levels.get(env_level, logging.CRITICAL)
logger = logging.getLogger(name)
logger.setLevel(level)
if not logger.handlers:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(
fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
))
logger.addHandler(handler)
return logger
+51
View File
@@ -0,0 +1,51 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "pypicokey"
dynamic = ["version", "readme"]
description = "PicoKey for Python"
requires-python = ">=3.8"
license = { file = "LICENSE" }
authors = [
{ name = "Pol Henarejos", email = "pol.henarejos@cttc.es" }
]
dependencies = [
"setuptools",
"cryptography>=3.3",
"pyusb",
"pycvc",
"pyscard>=2.3.1",
"libusb",
"libusb_package",
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Plugins",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
"Programming Language :: Python :: 3.8",
"Topic :: Security",
"Topic :: System :: Installation/Setup",
"Topic :: System :: Networking",
"Topic :: System :: Systems Administration",
"Topic :: Utilities",
]
[project.urls]
Homepage = "https://github.com/polhenarejos/pypicokey"
[tool.setuptools]
packages = [
"picokey",
"picokey.core"
]
include-package-data = true
[tool.setuptools.dynamic]
version = { attr = "picokey._version.__version__" }
readme = { file = "README.md" }
+4 -2
View File
@@ -36,7 +36,9 @@ INSTALL_REQUIRES = [
'cryptography>=3.3',
'pyusb',
'pycvc',
'pyscard<=2.2.2',
'pyscard>=2.3.1',
'libusb',
'libusb_package',
]
if sys.platform.startswith('win32'):
@@ -50,7 +52,7 @@ except(IOError, ImportError):
setup(
name='pypicokey',
packages=['picokey'],
packages=['picokey','picokey/core'],
version=version,
description='PicoKey for Python',
license='AGPL',