22 Commits

Author SHA1 Message Date
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
11 changed files with 369 additions and 59 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
+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:
+178 -39
View File
@@ -17,59 +17,133 @@
*/
"""
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
import usb.core
class Platform(enum.IntEnum):
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):
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
except CardRequestTimeoutException:
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
rdrs = readers()
if len(rdrs) > 0:
if (slot >= 0 and slot >= len(rdrs)):
raise Exception('Slot number out of range')
if (slot >= 0 and slot < len(rdrs)):
reader = rdrs[slot]
connection = reader_has_card(reader)
if (connection is None):
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
self.__card = connection
self.__connection_type = ConnectionType.SMARTCARD
self.__monitor = CardMonitor()
self.__observer = PicoCardObserver(self)
self.__monitor.addObserver(self.__observer)
break
if (self.__card is None):
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:
pass
if disconnected:
self.__device.close()
try:
self.__card = RescuePicoKey()
self.__connection_type = ConnectionType.RESCUE
self.__observer = PicoRescueObserver(self)
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)
try:
resp, sw1, sw2 = self.select_applet(rescue=True)
if (sw1 == 0x90 and sw2 == 0x00):
@@ -81,11 +155,39 @@ class PicoKey:
self.product = Product(Product.UNKNOWN)
self.version = (0, 0)
def transmit(self, apdu):
@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):
if (not self.__card):
return
if isinstance(self.__card, RescuePicoKey):
self.__monitor.stop()
self.__monitor = None
self.__observer = None
self.__card.close()
else:
self.__card.disconnect()
self.__card.release()
self.__card = None
def transmit(self, apdu: list[int]):
if (not self.__card):
raise Exception('No device connected')
response, sw1, sw2 = self.__card.transmit(apdu)
return response, sw1, sw2
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] = []):
if (not self.__card):
raise Exception('No device connected')
lc = []
dataf = []
if (data):
@@ -109,9 +211,12 @@ class PicoKey:
try:
response, sw1, sw2 = self.__card.transmit(apdu)
except CardConnectionException:
except Exception:
self.__card.reconnect()
response, sw1, sw2 = self.__card.transmit(apdu)
try:
response, sw1, sw2 = self.__card.transmit(apdu)
except Exception as e:
raise Exception("APDU transmission error after reconnect: " + str(e))
code = (sw1<<8|sw2)
if (sw1 != 0x90):
@@ -145,27 +250,61 @@ class PicoKey:
try:
response, sw1, sw2 = self.__card.transmit(apdu)
except CardConnectionException:
except Exception:
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):
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):
def select_applet(self, rescue : bool = 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 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:
resp, sw = self.send(0x1E, cla=0x80, p1=0x00, 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):
try:
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):
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):
data = bytes([bootkey_index & 0xFF, 1 if lock else 0])
self.send(0x1C, cla=0x80, p1=0x02, data=data)
+79
View File
@@ -0,0 +1,79 @@
"""
/*
* 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:
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)
+41 -3
View File
@@ -17,13 +17,21 @@
*/
"""
import os
import usb.core
import usb.util
import libusb_package
import usb.backend.libusb1
from .ICCD import ICCD
class RescuePicoKey:
def __init__(self):
self.__dev = None
self.__in = None
self.__out = None
self.__int = None
self.__active = None
class find_class(object):
def __init__(self, class_):
@@ -37,7 +45,12 @@ class RescuePicoKey:
return True
return False
devs = usb.core.find(find_all=True, custom_match=find_class(0x0B))
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)
devs = []
found = False
for dev in devs:
if (dev.manufacturer == 'Pol Henarejos'):
@@ -66,6 +79,24 @@ class RescuePicoKey:
if (not found):
raise Exception('Not found any Pico Key device')
@property
def device(self):
return self.__dev
def close(self):
if self.__dev:
usb.util.dispose_resources(self.__dev)
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)
@@ -77,8 +108,15 @@ class RescuePicoKey:
assert(self.__dev.write(self.__out, data, timeout) == len(data))
def exchange(self, data, timeout=2000):
self.write(data=data, timeout=timeout)
return self.read(timeout=timeout)
try:
self.write(data=data, timeout=timeout)
except Exception as e:
raise Exception("USB write error: " + str(e))
try:
ret = self.read(timeout=timeout)
except Exception as e:
raise Exception("USB read error: " + str(e))
return ret
def powerOn(self):
if (not self.__active):
+1
View File
@@ -5,3 +5,4 @@ from .APDU import APDUResponse
from .SWCodes import SWCodes
from .RescuePicoKey import RescuePicoKey
from .PhyData import PhyData, PhyCurve, PhyUsbItf, PhyLedDriver, PhyOpt
from .core import enums
+1 -1
View File
@@ -18,4 +18,4 @@
*/
"""
__version__ = "1.0"
__version__ = "1.1.7"
+1
View File
@@ -0,0 +1 @@
from .enums import *
+5
View File
@@ -0,0 +1,5 @@
import enum
class NamedIntEnum(enum.IntEnum):
def __str__(self):
return self.name
+51
View File
@@ -0,0 +1,51 @@
[build-system]
requires = ["setuptools>=61.0"]
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',