24 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
12 changed files with 904 additions and 54 deletions
+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()
+233 -45
View File
@@ -17,12 +17,19 @@
*/
"""
import sys
from typing import Optional
import threading
from .APDU import APDUResponse
from .SecureChannel import SecureChannel
from .RescuePicoKey import RescuePicoKey
from .RescueMonitor import RescueMonitor, RescueMonitorObserver
from .PhyData import PhyData
from .core import NamedIntEnum
from .core.exceptions import PicoKeyNotFoundError, PicoKeyInvalidStateError
import usb.core
from .core.log import get_logger
logger = get_logger("PicoKey")
class Platform(NamedIntEnum):
RP2040 = 0
@@ -36,62 +43,215 @@ class Product(NamedIntEnum):
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, force_rescue=False):
logger.debug("Initializing PicoKey...")
self.__apdu = []
self.__connection_type = ConnectionType.UNKNOWN
self.__monitor = None
self.__observer = None
self.__sc = None
if (force_rescue):
self.__card = None
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
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')
else:
from smartcard.CardType import AnyCardType
from smartcard.CardRequest import CardRequest
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
# connect to the card and perform a few transmits
self.__card.connect()
except Exception:
try:
self.__card = RescuePicoKey()
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
@property
def device(self):
return self.__card
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:
self.__card.disconnect()
self.__card.release()
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):
response, sw1, sw2 = self.__card.transmit(apdu)
return response, sw1, sw2
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, cla=0x00, p1=0x00, p2=0x00, ne=None, data=None, codes=[]):
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):
@@ -110,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 Exception:
self.__card.reconnect()
response, sw1, sw2 = self.__card.transmit(apdu)
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):
@@ -138,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
@@ -152,36 +327,40 @@ class PicoKey:
try:
response, sw1, sw2 = self.__card.transmit(apdu)
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):
try:
resp, sw = self.send(0x1E, cla=0x80, p1=0x00, ne=256)
self.select_applet()
resp, sw = self.send(0x1E, cla=0x80, p1=0x01, ne=256)
return PhyData.parse(resp)
except APDUResponse:
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')
@@ -199,6 +378,8 @@ class PicoKey:
}
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,
@@ -206,6 +387,13 @@ class PicoKey:
'boot_key': resp[2]
}
def secure_boot(self, bootkey_index=0, lock=False):
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)
+85 -8
View File
@@ -17,16 +17,34 @@
*/
"""
import os
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_):
@@ -40,16 +58,20 @@ class RescuePicoKey:
return True
return False
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:
print("RescuePicoKey: exception during usb.core.find:", 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):
@@ -66,17 +88,37 @@ 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()
@@ -88,31 +130,53 @@ class RescuePicoKey:
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):
logger.debug("Exchanging data with device")
try:
self.write(data=data, timeout=timeout)
except Exception as e:
raise Exception("USB write error: " + str(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:
raise Exception("USB read error: " + str(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):
@@ -120,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
+1
View File
@@ -6,3 +6,4 @@ 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.1.5"
__version__ = "1.3.2"
+1
View File
@@ -1 +1,2 @@
from .enums import *
from .exceptions import *
+39
View File
@@ -1,5 +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" }