16 Commits
tests ... door

Author SHA1 Message Date
b81b6954ef Writing, deleting don 2026-05-22 08:28:35 +02:00
61cce57401 Reading done, writing almost 2026-05-21 21:11:46 +02:00
7f9df9db91 Fix test test_timetable_models 2026-05-21 19:26:27 +02:00
0e8f1562c8 Add .coverage to gitignore 2026-05-21 19:25:58 +02:00
a8b5bea8cb Implement background scanner
for now just outputs uid as configured by test.py
2026-05-21 19:20:07 +02:00
f77b96ebcc use complex text from desfire lib 2026-05-21 19:19:27 +02:00
a4d047452a Get env vars with .env 2026-05-21 18:26:40 +02:00
c30516c2bc Finnish check_access 2026-05-21 18:00:05 +02:00
8b4c3cdec9 weekday zero index 2026-05-21 17:56:17 +02:00
9409ebacf3 desfire test app 2026-05-20 20:13:46 +02:00
831a653706 Start timetable checker 2026-05-20 20:02:10 +02:00
c9f1b1833d Debug api 2026-05-20 20:01:38 +02:00
a2d8ccfd7a Add pyscard 2026-05-20 20:00:59 +02:00
357d0d0d65 need to check a thing 2026-05-20 16:33:44 +02:00
fe91adad08 Add doorrouter 2026-05-19 19:57:33 +02:00
0d31b9c146 Merge branch 'tests' 2026-05-19 17:28:18 +02:00
14 changed files with 632 additions and 31 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
__pycache__ __pycache__
gatekeeper.db gatekeeper.db
.env
.coverage

View File

@@ -7,6 +7,8 @@ start dev server `uv run fastapi dev`
Swagger UI @ http://127.0.0.1:8000/docs Swagger UI @ http://127.0.0.1:8000/docs
start prod server `uv run fastapi run` start prod server `uv run fastapi run`
You need to set services.pcscd.enable = true; for the smartcard reader to work
Issues: Issues:
- `nix run` currently broken - `nix run` currently broken
- no door state - no door state

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select from sqlmodel import Session, select
from typing import List from typing import List
@@ -6,12 +6,16 @@ from ..model.models import Card
from ..services.database import engine, get_session, add_and_refresh from ..services.database import engine, get_session, add_and_refresh
from ..services.auth import auth_is_admin from ..services.auth import auth_is_admin
import uuid as gen_uuid import uuid as gen_uuid
from app.services.scanner import WriteNewCard, DeleteCard
card_router = APIRouter(prefix="/cards", tags=["Card"]) card_router = APIRouter(prefix="/cards", tags=["Card"])
def register_card(group_id: int): def register_card(group_id: int):
uuid = str(gen_uuid.uuid4()) #hier code für mifare registrierung key = WriteNewCard()
card = Card(group_id=group_id, uuid=uuid) if key == None:
print("No card registered. Check logs!")
raise HTTPException(status.HTTP_417_EXPECTATION_FAILED, detail="No card registered. Check logs!")
card = Card(group_id=group_id, uuid=key)
return card return card
@card_router.post("/{group_id}", response_model=Card) @card_router.post("/{group_id}", response_model=Card)
@@ -19,14 +23,15 @@ def add_card(*, db: Session = Depends(get_session), group_id: int, admin: bool =
card = register_card(group_id) card = register_card(group_id)
return add_and_refresh(db, card) return add_and_refresh(db, card)
@card_router.delete("/{card_id}") @card_router.get("/delete")
def del_card(*, db: Session = Depends(get_session), card_id: int, admin: bool = Depends(auth_is_admin)): def del_card(*, db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)):
card = db.get(Card, card_id) key = DeleteCard()
if card is None: # card = db.get(Card, card_id)
raise HTTPException(status_code=404, detail="Card not found") # if card is None:
db.delete(card) # raise HTTPException(status_code=404, detail="Card not found")
db.commit() # db.delete(card)
return {"message": "Card deleted successfully"} # db.commit()
# return {"message": "Card deleted successfully"}
##TBH not a big fan of having creation using group_id but deletion using card_id ##TBH not a big fan of having creation using group_id but deletion using card_id
@card_router.get("/{group_id}", response_model=List[Card]) @card_router.get("/{group_id}", response_model=List[Card])
def get_cards(*, db: Session = Depends(get_session), group_id: int, admin: bool = Depends(auth_is_admin)): def get_cards(*, db: Session = Depends(get_session), group_id: int, admin: bool = Depends(auth_is_admin)):

View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session
from app.services.database import get_session
from app.services.auth import auth_is_admin
import app.services.door as doorService
door_router = APIRouter(prefix="/door",tags=["Door"])
@door_router.put("/open")
def open_door(db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)):
doorService.opendoor()
@door_router.put("/close")
def open_door(db: Session = Depends(get_session), admin: bool = Depends(auth_is_admin)):
doorService.closedoor()
@door_router.post("/test")
def test_access(input: str, db: Session = Depends(get_session)):
return doorService.checkAccess(input, db=db)

View File

@@ -1,18 +1,25 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from .controllers import userManager, cardManager, groupManager, aaManager from dotenv import load_dotenv
from .services.database import create_db_and_tables
from .controllers import userManager, cardManager, groupManager, aaManager, doorManager
from .services.database import create_db_and_tables, get_session
from .services.auth import token_router, create_first_user from .services.auth import token_router, create_first_user
from app.services.scanner import BackgroundScanner
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
scanner = BackgroundScanner(db=get_session())
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
load_dotenv()
create_db_and_tables() create_db_and_tables()
create_first_user() create_first_user()
print("Database created and tables initialized.") print("Database created and tables initialized.")
scanner.start()
yield yield
#scanner.stop()
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
@@ -22,4 +29,5 @@ app.include_router(token_router)
app.include_router(userManager.user_router) app.include_router(userManager.user_router)
app.include_router(groupManager.group_router) app.include_router(groupManager.group_router)
app.include_router(cardManager.card_router) app.include_router(cardManager.card_router)
app.include_router(aaManager.aa_router) app.include_router(aaManager.aa_router)
app.include_router(doorManager.door_router)

View File

@@ -1,5 +1,6 @@
from sqlmodel import Field, Relationship, Session, SQLModel from sqlmodel import Field, Relationship, Session, SQLModel
from typing import List from typing import List
from datetime import time
class Base(SQLModel): class Base(SQLModel):
pass pass
@@ -88,8 +89,8 @@ class Card(Base, table=True):
group: GroupDB | None = Relationship(back_populates="cards") group: GroupDB | None = Relationship(back_populates="cards")
class TimetableBase(Base): class TimetableBase(Base):
weekday: int = Field(le=7, ge=1) weekday: int = Field(le=6, ge=0)
starttime: str starttime: time
duration: int = Field(gt=0, lt=1440) duration: int = Field(gt=0, lt=1440)
class Timetable(TimetableBase, table=True): class Timetable(TimetableBase, table=True):

54
app/services/door.py Normal file
View File

@@ -0,0 +1,54 @@
import paho.mqtt.client as mqttClient
import paho.mqtt.publish as publish
from sqlmodel import select
from fastapi import Depends, HTTPException, status
from sqlalchemy.orm import selectinload
import sqlalchemy.exc as exc
import datetime
from app.services.database import Session, get_session
from app.model.models import *
doorIsOpen = True
client = mqttClient.Client(client_id="", userdata=None, protocol=mqttClient.MQTTv5)
client.tls_set(tls_version=mqttClient.ssl.PROTOCOL_TLS)
client.username_pw_set("username", "passwort")
#client.connect("host", port=8883)
# I think this could also be gpio controlled
#See: https://github.com/technyon/nuki_hub#gpio-lock-control-optional
def openDoor():
doorIsOpen = True
publish.single(topic="/lock/action", payload="unlock")
pass
def closeDoor():
doorIsOpen = False
publish.single(topic="/lock/action", payload="lock")
pass
def isDoorOpen():
return doorIsOpen
def checkAccess(uuid: str, db: Session = Depends(get_session)):
try:
current_weekday = datetime.datetime.weekday(datetime.date.today())
current_time = datetime.datetime.now()
card = db.exec(select(Card).where(Card.uuid == uuid)).one()
for auth in card.group.accessauths:
print(f"checking auth: {auth.name}")
for timetable in auth.timetables:
print(f" checking timetable {timetable.id}")
print(f" comparing weekday: CUR:{current_weekday} TT:{timetable.weekday}")
if current_weekday == timetable.weekday:
starttime = datetime.datetime.combine(datetime.date.today(), timetable.starttime)
endtime = starttime + datetime.timedelta(minutes=timetable.duration)
print(f" comparing time: Start:{starttime} Current:{current_time} End:{endtime}")
if starttime < current_time < endtime:
return True
return False
except exc.NoResultFound:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)

303
app/services/scanner.py Normal file
View File

@@ -0,0 +1,303 @@
import logging
import threading
import time
import os
import secrets
from typing import Optional
from sqlmodel import Session
from dotenv import load_dotenv
from fastapi import HTTPException, status
from smartcard.CardRequest import CardRequest
from smartcard.CardType import AnyCardType
from smartcard.Exceptions import CardRequestTimeoutException
from desfire import DESFire, DESFireKey, PCSCDevice, diversify_key, get_list, to_hex_string
from desfire.enums import DESFireCommunicationMode, DESFireFileType, DESFireKeySettings, DESFireKeyType
from desfire.schemas import FilePermissions, FileSettings, KeySettings
import desfire.exceptions as desExceptions
#ENV vars
load_dotenv()
MIFARE_APP_MASTER_KEY = os.getenv('MIFARE_APP_MASTER_KEY')
MIFARE_ACL_READ_BASE_KEY = os.getenv('MIFARE_ACL_READ_BASE_KEY')
MIFARE_ACL_WRITE_BASE_KEY = os.getenv('MIFARE_ACL_WRITE_BASE_KEY')
# Constants
MIFARE_APP_ID = "DEAFFE" # 7 bytes
MIFARE_ACL_READ_BASE_KEY_ID = 0x1
MIFARE_ACL_WRITE_BASE_KEY_ID = 0x2
MIFARE_SYS_ID = "FF0000" # 3 bytes, can essentially be anything
MIFARE_ENCRYPTED_FILE_ID = 0x1
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def checkForKey():
if MIFARE_APP_MASTER_KEY == None:
logger.critical("NO MASTER KEY LOADED")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="No key loaded! Check application.")
def getCardService(timeout: int = 10):
cardtype = AnyCardType()
cardrequest = CardRequest(timeout=timeout, cardType=cardtype)
print("Please present DESfire tag...")
try:
cardservice = cardrequest.waitforcard()
except CardRequestTimeoutException:
logger.error("No tag detected within the timeout.")
raise Exception
return cardservice
def DeleteCard():
try:
checkForKey()
from app.main import scanner as scannerThread
scannerThread.stop()
cardservice = getCardService(15)
cardservice.connection.connect()
# Create Desfire object
desfire = DESFire(PCSCDevice(cardservice.connection.component))
# Create Key objects
AES_NULL_KEY_DATA = "00" * 8
aes_keysettings = KeySettings(
key_type=DESFireKeyType.DF_KEY_AES,
)
key_settings = desfire.get_key_setting()
aes_null_key = DESFireKey(key_settings, AES_NULL_KEY_DATA)
aes_master_key = DESFireKey(aes_keysettings, MIFARE_APP_MASTER_KEY)
desfire.authenticate(0x0, aes_null_key)
applications = desfire.get_application_ids()
logger.debug(f"Applications: {applications}")
if len(applications) == 0:
raise HTTPException(status_code=status.HTTP_410_GONE, detail="No applications on card")
desfire.select_application(MIFARE_APP_ID)
desfire.authenticate(0x0, aes_master_key)
try:
desfire.delete_application(MIFARE_APP_ID)
except Exception:
pass
scannerThread.start()
except Exception as e:
logger.error(f"Error in deletion function: {e}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {e}")
def WriteNewCard():
try:
checkForKey()
from app.main import scanner as scannerThread
scannerThread.stop()
cardtype = AnyCardType()
cardrequest = CardRequest(timeout=10, cardType=cardtype)
print("Please present DESfire tag...")
try:
cardservice = cardrequest.waitforcard()
except CardRequestTimeoutException:
logger.error("No tag detected within the timeout.")
return None
cardservice.connection.connect()
# Create Desfire object
desfire = DESFire(PCSCDevice(cardservice.connection.component))
# Create Key objects
AES_NULL_KEY_DATA = "00" * 16
aes_keysettings = KeySettings(
key_type=DESFireKeyType.DF_KEY_AES,
)
aes_null_key = DESFireKey(aes_keysettings, AES_NULL_KEY_DATA)
aes_master_key = DESFireKey(aes_keysettings, MIFARE_APP_MASTER_KEY)
keysetting = desfire.get_key_setting()
desKey = DESFireKey(keysetting, "00" * 8)
# Authenticate with default DES key
print("Authenticating with default DES key...")
desfire.authenticate(0x0, desKey)
#get uid
uid = desfire.get_real_uid()
# Set default key
print("Setting default key...")
desfire.change_default_key(aes_null_key, 0x0)
# Create application
print("Creating application...")
app_settings = KeySettings(
settings=[
DESFireKeySettings.KS_ALLOW_CHANGE_MK,
DESFireKeySettings.KS_LISTING_WITHOUT_MK,
DESFireKeySettings.KS_CREATE_DELETE_WITHOUT_MK,
DESFireKeySettings.KS_CONFIGURATION_CHANGEABLE,
],
key_type=DESFireKeyType.DF_KEY_AES,
)
desfire.create_application(MIFARE_APP_ID, app_settings, 4)
# Verify application creation
applications = desfire.get_application_ids()
assert len(applications) == 1
assert applications[0] == get_list(MIFARE_APP_ID)
print(" - Application created successfully.")
# Select application
desfire.select_application(MIFARE_APP_ID)
#Auth again as 0key
aes_null_auth_key = DESFireKey(aes_keysettings, AES_NULL_KEY_DATA)
desfire.authenticate(0x0, aes_null_auth_key)
# Authenticate with AES key, as this has been set as the default key
aes_app_mk = DESFireKey(aes_keysettings, MIFARE_APP_MASTER_KEY)
desfire.change_key(0x0, aes_null_key, aes_app_mk, 0x1)
print("new key auth")
desfire.authenticate(0x0, aes_app_mk)
#generate div data
diversification_data = [0x01] + uid + get_list(MIFARE_APP_ID) + get_list(MIFARE_SYS_ID)
read_div_key_bytes = diversify_key(get_list(MIFARE_ACL_READ_BASE_KEY), diversification_data, pad_to_32=False)
write_div_key_bytes = diversify_key(get_list(MIFARE_ACL_WRITE_BASE_KEY), diversification_data, pad_to_32=False)
print("Changing file read key...")
aes_file_read_key = DESFireKey(aes_keysettings, read_div_key_bytes)
desfire.change_key(MIFARE_ACL_READ_BASE_KEY_ID, aes_null_key, aes_file_read_key, 0x1)
print("Changing file write key...")
aes_file_write_key = DESFireKey(aes_keysettings, write_div_key_bytes)
desfire.change_key(MIFARE_ACL_WRITE_BASE_KEY_ID, aes_null_key, aes_file_write_key, 0x1)
print("Create encrypted file containing UUID...")
file_settings = FileSettings(
file_size=16,
encryption=DESFireCommunicationMode.ENCRYPTED,
permissions=FilePermissions(
read_key=MIFARE_ACL_READ_BASE_KEY_ID,
write_key=MIFARE_ACL_WRITE_BASE_KEY_ID,
),
file_type=DESFireFileType.MDFT_STANDARD_DATA_FILE,
)
desfire.create_standard_file(MIFARE_ENCRYPTED_FILE_ID, file_settings)
print("Read and verify file settings again...")
file_data = desfire.get_file_settings(MIFARE_ENCRYPTED_FILE_ID)
assert file_data.file_size == 16
assert file_data.encryption == DESFireCommunicationMode.ENCRYPTED
assert file_data.permissions is not None
assert file_data.permissions.read_access == MIFARE_ACL_READ_BASE_KEY_ID
assert file_data.permissions.write_access == MIFARE_ACL_WRITE_BASE_KEY_ID
assert file_data.file_type == DESFireFileType.MDFT_STANDARD_DATA_FILE
print(" - File created successfully.")
print("Writing UID to encrypted file...")
key = secrets.token_hex(16)
desfire.write_file_data(MIFARE_ENCRYPTED_FILE_ID, 0x0, file_data.encryption, get_list(key))
print("Reading from encrypted file...")
rdata = desfire.read_file_data(MIFARE_ENCRYPTED_FILE_ID, file_data)
assert rdata == get_list(key)
print(" - Data written successfully.")
scannerThread.start()
return key
except Exception as e:
logger.error(f"Error in write function: {e}", exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Error: {e}")
class BackgroundScanner:
def __init__(self, db):
self.db = db
self.is_running = False
self.thread: Optional[threading.Thread] = None
def start(self):
if self.is_running:
logger.info("Scanner already running")
return
self.is_running = True
self.thread = threading.Thread(target=self._scan_loop, daemon=True)
self.thread.start()
logger.info("Scanner started")
def stop(self):
self.is_running = False
if self.thread:
self.thread.join()
logger.info("Scanner stopped")
def _scan_loop(self):
while self.is_running:
try:
card_content = self._read_card()
if card_content:
logger.info(to_hex_string(card_content))
time.sleep(5)
logger.debug("READY after success")
#self._check_db(card_content)
else:
time.sleep(0.5)
logger.debug("READY after timout")
except Exception as e:
logger.error(f"Error in scan function: {e}", exc_info=True)
time.sleep(6)
def _read_card(self):
cardtype = AnyCardType()
cardrequest = CardRequest(timeout=5, cardType=cardtype)
try:
cardservice = cardrequest.waitforcard()
except CardRequestTimeoutException:
logger.debug("No tag detected within the timeout.")
return
try:
cardservice.connection.connect()
# Create Desfire object
desfire = DESFire(PCSCDevice(cardservice.connection.component))
aes_keysettings = KeySettings(
key_type=DESFireKeyType.DF_KEY_AES,
)
# Get real UID
mk = DESFireKey(desfire.get_key_setting(), "00" * 8)
desfire.authenticate(0x0, mk)
#To get the uid you have to auth with an empty (default) key
uid = desfire.get_real_uid()
applications = desfire.get_application_ids()
try:
assert len(applications) == 1
assert applications[0] == get_list(MIFARE_APP_ID)
except AssertionError:
logger.error("No application found!")
time.sleep(4)
return None
#Then use the key derivation with that uid, the appid, the sysid
diversification_data = [0x01] + uid + get_list(MIFARE_APP_ID) + get_list(MIFARE_SYS_ID)
read_div_key_bytes = diversify_key(get_list(MIFARE_ACL_READ_BASE_KEY), diversification_data, pad_to_32=False)
#Log in with derived read key
logger.info("Start auth")
aes_app_read_key = DESFireKey(aes_keysettings, read_div_key_bytes)
desfire.select_application(MIFARE_APP_ID)
desfire.authenticate(MIFARE_ACL_READ_BASE_KEY_ID, aes_app_read_key)
logger.info("Read data")
file_data = desfire.get_file_settings(MIFARE_ENCRYPTED_FILE_ID)
logger.info(f"File settings: {file_data}")
rdata = desfire.read_file_data(MIFARE_ENCRYPTED_FILE_ID, file_data)
return rdata
except Exception as e:
logger.error(f"something went wrong: {e}")
time.sleep(5)

24
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1775710090, "lastModified": 1778869304,
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", "narHash": "sha256-30sZNZoA1cqF5JNO9fVX+wgiQYjB7HJqqJ4ztCDeBZE=",
"owner": "nixos", "owner": "nixos",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "4c1018dae018162ec878d42fec712642d214fdfa", "rev": "d233902339c02a9c334e7e593de68855ad26c4cb",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -29,11 +29,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1773870109, "lastModified": 1776659114,
"narHash": "sha256-ZoTdqZP03DcdoyxvpFHCAek4bkPUTUPUF3oCCgc3dP4=", "narHash": "sha256-qapCOQmR++yZSY43dzrp3wCrkOTLpod+ONtJWBk6iKU=",
"owner": "pyproject-nix", "owner": "pyproject-nix",
"repo": "build-system-pkgs", "repo": "build-system-pkgs",
"rev": "b6e74f433b02fa4b8a7965ee24680f4867e2926f", "rev": "ffaa2161dd5d63e0e94591f86b54fc239660fb2e",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -49,11 +49,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1776120154, "lastModified": 1778901413,
"narHash": "sha256-mtIBTmVKzyoFYoAGdd8Cd7iswFna9YQVyjObZLXPO64=", "narHash": "sha256-GSKXTAnFqRAMlZkJrIPcQMYf+lpMr66K3i60mB9STvc=",
"owner": "pyproject-nix", "owner": "pyproject-nix",
"repo": "pyproject.nix", "repo": "pyproject.nix",
"rev": "29dc4e9960d2b7f122b52b155e0e8f87cd5c5c08", "rev": "a228447c3e179d477c1b6246ef3efa8cfe3c469a",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -80,11 +80,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1776114780, "lastModified": 1779269674,
"narHash": "sha256-aYUgp40qkY7oNSm+G9A/woNYP+eDeFM0bYckfmxEUiY=", "narHash": "sha256-P1LHCRdYpdtHAEzuEsNHrI6d9mVPl5a2fyFDZGHNVbI=",
"owner": "pyproject-nix", "owner": "pyproject-nix",
"repo": "uv2nix", "repo": "uv2nix",
"rev": "73ff87a3e489b07b9cf842f917963a9e40d49225", "rev": "69aec536f6d1acc415ed2e20299312802aba98c6",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -58,6 +58,13 @@
lib.composeManyExtensions [ lib.composeManyExtensions [
pyproject-build-systems.overlays.wheel pyproject-build-systems.overlays.wheel
overlay overlay
(final: prev: {
pyscard = prev.pyscard.overrideAttrs (old: {
nativeBuildInputs = (old.nativeBuildInputs or []) ++ [ pkgs.swig pkgs.pkg-config];
buildInputs = (old.buildInputs or []) ++ [ pkgs.pcsclite.dev ];
NIX_CFLAGS_COMPILE = "-I${pkgs.pcsclite.dev}/include/PCSC";
});
})
] ]
) )
); );
@@ -76,11 +83,16 @@
packages = [ packages = [
virtualenv virtualenv
pkgs.uv pkgs.uv
pkgs.pcsclite
pkgs.pcsclite.dev
pkgs.swig
pkgs.pkg-config
]; ];
env = { env = {
UV_NO_SYNC = "1"; UV_NO_SYNC = "1";
UV_PYTHON = pythonSet.python.interpreter; UV_PYTHON = pythonSet.python.interpreter;
UV_PYTHON_DOWNLOADS = "never"; UV_PYTHON_DOWNLOADS = "never";
LD_LIBRARY_PATH = "${lib.getLib pkgs.pcsclite}/lib";
}; };
shellHook = '' shellHook = ''
unset PYTHONPATH unset PYTHONPATH

View File

@@ -15,6 +15,8 @@ dependencies = [
"pytest>=9.0.3", "pytest>=9.0.3",
"requests>=2.33.1", "requests>=2.33.1",
"pytest-cov>=7.1.0", "pytest-cov>=7.1.0",
"setuptools>=82.0.1",
"pyscard>=2.3.1",
] ]
[tool.uv.sources] [tool.uv.sources]
@@ -22,3 +24,4 @@ python-desfire = { git = "https://github.com/waza-ari/python-desfire" }
[tool.uv.extra-build-dependencies] [tool.uv.extra-build-dependencies]
python-desfire = ["poetry"] python-desfire = ["poetry"]
"pyscard" = ["setuptools"]

164
test.py Normal file
View File

@@ -0,0 +1,164 @@
"""
This is a more involved example that performs initial configuration (often called personalization) of a DESFire card.
It performs the following steps:
1. Authenticate with the default DES key
3. Change the default key
2. Create an application
4. Change the application master key
6. Create a read and write key (diversified)
7. Create an encrypted file
8. Write the UID to the encrypted file
"""
import logging
import os
from smartcard.CardRequest import CardRequest
from smartcard.CardType import AnyCardType
from smartcard.Exceptions import CardRequestTimeoutException
from desfire import DESFire, DESFireKey, PCSCDevice, diversify_key, get_list, to_hex_string
from desfire.enums import DESFireCommunicationMode, DESFireFileType, DESFireKeySettings, DESFireKeyType
from desfire.schemas import FilePermissions, FileSettings, KeySettings
from dotenv import load_dotenv
# Please make sure to yet your own keys here before running this script
load_dotenv()
MIFARE_APP_MASTER_KEY = os.getenv('MIFARE_APP_MASTER_KEY')
MIFARE_ACL_READ_BASE_KEY = os.getenv('MIFARE_ACL_READ_BASE_KEY')
MIFARE_ACL_WRITE_BASE_KEY = os.getenv('MIFARE_ACL_WRITE_BASE_KEY')
# Constants
MIFARE_APP_ID = "DEAFFE" # 7 bytes
MIFARE_ACL_READ_BASE_KEY_ID = 0x1
MIFARE_ACL_WRITE_BASE_KEY_ID = 0x2
MIFARE_SYS_ID = "FF0000" # 3 bytes, can essentially be anything
MIFARE_ENCRYPTED_FILE_ID = 0x1
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
cardtype = AnyCardType()
cardrequest = CardRequest(timeout=30, cardType=cardtype)
print("Please present DESfire tag...")
try:
cardservice = cardrequest.waitforcard()
except CardRequestTimeoutException:
print("No tag detected within the timeout.")
raise
cardservice.connection.connect()
# Create Desfire object
desfire = DESFire(PCSCDevice(cardservice.connection.component))
# Create Key objects
AES_NULL_KEY_DATA = "00" * 16
aes_keysettings = KeySettings(
key_type=DESFireKeyType.DF_KEY_AES,
)
aes_null_key = DESFireKey(aes_keysettings, AES_NULL_KEY_DATA)
# Authenticate with default DES key
print("Authenticating with default DES key...")
key_settings = desfire.get_key_setting()
mk = DESFireKey(key_settings, "00" * 8)
desfire.authenticate(0x0, mk)
# Get real UID
print("Getting real UID...")
uid = desfire.get_real_uid()
print(" - UID: ", to_hex_string(uid))
# Set default key
print("Setting default key...")
desfire.change_default_key(aes_null_key, 0x0)
# Create application
print("Creating application...")
app_settings = KeySettings(
settings=[
DESFireKeySettings.KS_ALLOW_CHANGE_MK,
DESFireKeySettings.KS_LISTING_WITHOUT_MK,
DESFireKeySettings.KS_CREATE_DELETE_WITHOUT_MK,
DESFireKeySettings.KS_CONFIGURATION_CHANGEABLE,
],
key_type=DESFireKeyType.DF_KEY_AES,
)
desfire.create_application(MIFARE_APP_ID, app_settings, 4)
# Verify application creation
applications = desfire.get_application_ids()
assert len(applications) == 1
assert applications[0] == get_list(MIFARE_APP_ID)
print(" - Application created successfully.")
# Select application
print("Selecting application...")
desfire.select_application(MIFARE_APP_ID)
# Authenticate with AES key, as this has been set as the default key
print("Authenticating with AES key...")
# Create a new one as key data would be overriden by session data
aes_null_auth_key = DESFireKey(aes_keysettings, AES_NULL_KEY_DATA)
desfire.authenticate(0x0, aes_null_auth_key)
# Change Application master key
print("Changing application master key (AMK)...")
aes_app_mk = DESFireKey(aes_keysettings, MIFARE_APP_MASTER_KEY)
desfire.change_key(0x0, aes_null_key, aes_app_mk, 0x1)
# Re-Authenticate with new AES key
print("Re-authenticating with new AES key...")
desfire.authenticate(0x0, aes_app_mk)
# Change file read and write keys (diversified)
diversification_data = [0x01] + uid + get_list(MIFARE_APP_ID) + get_list(MIFARE_SYS_ID)
read_div_key_bytes = diversify_key(get_list(MIFARE_ACL_READ_BASE_KEY), diversification_data, pad_to_32=False)
write_div_key_bytes = diversify_key(get_list(MIFARE_ACL_WRITE_BASE_KEY), diversification_data, pad_to_32=False)
print("Changing file read key...")
aes_file_read_key = DESFireKey(aes_keysettings, read_div_key_bytes)
desfire.change_key(MIFARE_ACL_READ_BASE_KEY_ID, aes_null_key, aes_file_read_key, 0x1)
print("Changing file write key...")
aes_file_write_key = DESFireKey(aes_keysettings, write_div_key_bytes)
desfire.change_key(MIFARE_ACL_WRITE_BASE_KEY_ID, aes_null_key, aes_file_write_key, 0x1)
print("Create encrypted file containing UID...")
file_settings = FileSettings(
file_size=8,
encryption=DESFireCommunicationMode.ENCRYPTED,
permissions=FilePermissions(
read_key=MIFARE_ACL_READ_BASE_KEY_ID,
write_key=MIFARE_ACL_WRITE_BASE_KEY_ID,
),
file_type=DESFireFileType.MDFT_STANDARD_DATA_FILE,
)
desfire.create_standard_file(MIFARE_ENCRYPTED_FILE_ID, file_settings)
print("Read and verify file settings again...")
file_data = desfire.get_file_settings(MIFARE_ENCRYPTED_FILE_ID)
assert file_data.file_size == 8
assert file_data.encryption == DESFireCommunicationMode.ENCRYPTED
assert file_data.permissions is not None
assert file_data.permissions.read_access == MIFARE_ACL_READ_BASE_KEY_ID
assert file_data.permissions.write_access == MIFARE_ACL_WRITE_BASE_KEY_ID
assert file_data.file_type == DESFireFileType.MDFT_STANDARD_DATA_FILE
print(" - File created successfully.")
print("Writing UID to encrypted file...")
data = [0x0] + uid
assert len(data) == 8
desfire.write_file_data(MIFARE_ENCRYPTED_FILE_ID, 0x0, file_data.encryption, get_list(data))
print("Reading from encrypted file...")
rdata = desfire.read_file_data(MIFARE_ENCRYPTED_FILE_ID, file_data)
assert rdata == data
print(" - Data written successfully.")
print("Personalization finished.")

View File

@@ -1,4 +1,5 @@
import pytest import pytest
import datetime
from app.model.models import ( from app.model.models import (
UserBase, UserResponse, UserCreate, UserDB, UserUpdate, UserBase, UserResponse, UserCreate, UserDB, UserUpdate,
GroupBase, GroupCreate, GroupDB, GroupResponse, GroupBase, GroupCreate, GroupDB, GroupResponse,
@@ -68,13 +69,13 @@ def test_timetable_models():
# Test TimetableBase with valid values # Test TimetableBase with valid values
timetable = TimetableCreate(weekday=1, starttime="09:00", duration=120) timetable = TimetableCreate(weekday=1, starttime="09:00", duration=120)
assert timetable.weekday == 1 assert timetable.weekday == 1
assert timetable.starttime == "09:00" assert timetable.starttime == datetime.time(9, 0)
assert timetable.duration == 120 assert timetable.duration == 120
# Test boundary values # Test boundary values
max_duration = TimetableCreate(weekday=7, starttime="23:59", duration=1439) max_duration = TimetableCreate(weekday=6, starttime="23:59", duration=1439)
assert max_duration.duration == 1439 assert max_duration.duration == 1439
assert max_duration.weekday == 7 assert max_duration.weekday == 6
def test_token_models(): def test_token_models():

26
uv.lock generated
View File

@@ -613,10 +613,12 @@ dependencies = [
{ name = "poetry" }, { name = "poetry" },
{ name = "pwdlib", extra = ["argon2"] }, { name = "pwdlib", extra = ["argon2"] },
{ name = "pyjwt", extra = ["crypto"] }, { name = "pyjwt", extra = ["crypto"] },
{ name = "pyscard" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "python-desfire" }, { name = "python-desfire" },
{ name = "requests" }, { name = "requests" },
{ name = "setuptools" },
{ name = "sqlmodel" }, { name = "sqlmodel" },
] ]
@@ -627,10 +629,12 @@ requires-dist = [
{ name = "poetry", specifier = ">=2.3.4" }, { name = "poetry", specifier = ">=2.3.4" },
{ name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.0" }, { name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.0" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.1" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.1" },
{ name = "pyscard", specifier = ">=2.3.1" },
{ name = "pytest", specifier = ">=9.0.3" }, { name = "pytest", specifier = ">=9.0.3" },
{ name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-cov", specifier = ">=7.1.0" },
{ name = "python-desfire", git = "https://github.com/waza-ari/python-desfire" }, { name = "python-desfire", git = "https://github.com/waza-ari/python-desfire" },
{ name = "requests", specifier = ">=2.33.1" }, { name = "requests", specifier = ">=2.33.1" },
{ name = "setuptools", specifier = ">=82.0.1" },
{ name = "sqlmodel", specifier = ">=0.0.38" }, { name = "sqlmodel", specifier = ">=0.0.38" },
] ]
@@ -1230,6 +1234,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
] ]
[[package]]
name = "pyscard"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/93/c9/65c68738a94b44b67b3c5e68a815890bbd225f2ae11ef1ace9b61fa9d5f3/pyscard-2.3.1.tar.gz", hash = "sha256:a24356f57a0a950740b6e54f51f819edd5296ee8892a6625b0da04724e9e6c13", size = 160650, upload-time = "2025-10-29T15:49:08.353Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/e2/d8f967fe6fee82c02ee35d736e8707d65ed1e37fa7635f3bdca96bdcd2ff/pyscard-2.3.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c11a596407e18cdcf16a4ccd8cdeaa55846d4f7ec2eefc529483e201f6906658", size = 170956, upload-time = "2025-10-29T15:53:32.072Z" },
{ url = "https://files.pythonhosted.org/packages/ca/a1/508fc5733e2ce01a71c6abc94dae543311cf9d0962e309b0a78b4cb4031a/pyscard-2.3.1-cp313-cp313-macosx_15_0_x86_64.whl", hash = "sha256:72b1ab922fad5e050144ec72762e36741271b09d2389cda9b976b61ee0564e71", size = 138809, upload-time = "2025-10-29T15:49:06.528Z" },
{ url = "https://files.pythonhosted.org/packages/18/a4/5481cd54f882ea61bfc334667f1e5126ac51124a464297dbe24592288b14/pyscard-2.3.1-cp313-cp313-win32.whl", hash = "sha256:a0b59d1961ff9fb15d980ad64edae13e4512b7e641ea8959e86133f34091aa5c", size = 139294, upload-time = "2025-10-29T15:53:35.805Z" },
{ url = "https://files.pythonhosted.org/packages/8c/2b/c98e7bcf45958905d1bfdab59fe5acd67fe9ff0ad78145c4783886c38c84/pyscard-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:df2b256bc719b701807114bdd179f7b303f309a954d5689188b088adfc33ead2", size = 145501, upload-time = "2025-10-29T15:53:33.549Z" },
{ url = "https://files.pythonhosted.org/packages/93/a6/74cca0ec3c08f35c1a7468a8508d0e3989e63298d3a4b1f928542719aebc/pyscard-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:f58b46cd78455a29a0abceabff21b37da81a385a737b29e6dd5e25acb7e3f3da", size = 140758, upload-time = "2025-10-29T15:53:34.542Z" },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.3" version = "9.0.3"
@@ -1529,6 +1546,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fa/eb/d875669993b762556ae8b2efd86219943b4c0864d22204d622a9aee3052b/sentry_sdk-2.58.0-py2.py3-none-any.whl", hash = "sha256:688d1c704ddecf382ea3326f21a67453d4caa95592d722b7c780a36a9d23109e", size = 460919, upload-time = "2026-04-13T17:23:24.675Z" }, { url = "https://files.pythonhosted.org/packages/fa/eb/d875669993b762556ae8b2efd86219943b4c0864d22204d622a9aee3052b/sentry_sdk-2.58.0-py2.py3-none-any.whl", hash = "sha256:688d1c704ddecf382ea3326f21a67453d4caa95592d722b7c780a36a9d23109e", size = 460919, upload-time = "2026-04-13T17:23:24.675Z" },
] ]
[[package]]
name = "setuptools"
version = "82.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
]
[[package]] [[package]]
name = "shellingham" name = "shellingham"
version = "1.5.4" version = "1.5.4"