29 Commits

Author SHA1 Message Date
9160b312c7 Fix the things i broke using logger.info 2026-05-23 20:35:12 +02:00
07669fc1fc I don't know how to test hardware functions 2026-05-23 20:27:16 +02:00
94d428c9d0 Change all instances of print() to logger.info() 2026-05-23 20:17:43 +02:00
a4bedba628 Remove mqtt 2026-05-23 20:09:12 +02:00
e6a248529b Fix test test_create_first_user() 2026-05-23 20:08:10 +02:00
713d41e81c Add door.py tests 2026-05-23 19:49:02 +02:00
6ec3bc5f14 Improve assign_accessauth test and change already assigned to 409 2026-05-23 18:58:59 +02:00
9e6510f465 Remove unused test 2026-05-23 18:58:20 +02:00
5a6cd970dd Fix change_accessauth() not changing timetables
Also fix the test not actually checking the response :/
2026-05-23 18:41:50 +02:00
3d1893d84e Fix door check db session 2026-05-23 17:32:28 +02:00
495535a6de Scanner write-read-delete workflow working! 2026-05-23 00:30:03 +02:00
30b86dad5e Increase login time 2026-05-22 23:14:55 +02:00
92eef82e0a Slight improvements 2026-05-22 21:56:30 +02:00
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
23 changed files with 739 additions and 110 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,6 @@
from fastapi import APIRouter, Depends, HTTPException import logging
logger = logging.getLogger(__name__)
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select from sqlmodel import Session, select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from typing import List from typing import List
@@ -12,7 +14,7 @@ aa_router = APIRouter(prefix="/aa", tags=["AccessAuth"])
@aa_router.post("/", response_model=AccessAuthorizationResponse) @aa_router.post("/", response_model=AccessAuthorizationResponse)
def add_accessauth(*, db: Session = Depends(get_session), aa: AccessAuthorizationCreate, admin: bool = Depends(auth_is_admin)): def add_accessauth(*, db: Session = Depends(get_session), aa: AccessAuthorizationCreate, admin: bool = Depends(auth_is_admin)):
print("Creating accessauth with data: ", aa) logger.info(f"Creating accessauth with data: {aa}")
timetables = [Timetable.model_validate(t) for t in aa.timetables] timetables = [Timetable.model_validate(t) for t in aa.timetables]
db_aa = AccessAuthorizationDB( db_aa = AccessAuthorizationDB(
name=aa.name, name=aa.name,
@@ -44,7 +46,7 @@ def assign_accessauth(*, db: Session = Depends(get_session), group_id: int, aa_i
if db_aa is None: if db_aa is None:
raise HTTPException(status_code=404, detail="AA not found") raise HTTPException(status_code=404, detail="AA not found")
if db_aa in db_group.accessauths: if db_aa in db_group.accessauths:
raise HTTPException(status_code=200, detail="AA already assigned to group") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="AA already assigned to group")
db_group.accessauths.append(db_aa) db_group.accessauths.append(db_aa)
return add_and_refresh(db, db_group) return add_and_refresh(db, db_group)
@@ -66,7 +68,12 @@ def change_accessauth(*, db: Session = Depends(get_session), aa_id: int, aa: Acc
db_aa = db.get(AccessAuthorizationDB, aa_id) db_aa = db.get(AccessAuthorizationDB, aa_id)
if db_aa is None: if db_aa is None:
raise HTTPException(status_code=404, detail="AccessAuthorization not found") raise HTTPException(status_code=404, detail="AccessAuthorization not found")
aa_data = aa.dict(exclude_unset=True) aa_data = aa.model_dump(exclude_unset=True)
if "timetables" in aa_data and aa_data["timetables"] is not None:
db_aa.timetables.clear()
timetables = [Timetable.model_validate(t) for t in aa_data["timetables"]]
db_aa.timetables = timetables
aa_data.pop("timetables")
db_aa.sqlmodel_update(aa_data) db_aa.sqlmodel_update(aa_data)
return add_and_refresh(db, db_aa) return add_and_refresh(db, db_aa)

View File

@@ -1,17 +1,24 @@
from fastapi import APIRouter, Depends, HTTPException import logging
logger = logging.getLogger(__name__)
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
from sqlalchemy.exc import NoResultFound
from ..model.models import Card 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:
logger.info("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,15 +26,19 @@ 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: logger.info(key)
raise HTTPException(status_code=404, detail="Card not found") try:
card = db.exec(select(Card).where(Card.uuid == key)).one()
except NoResultFound:
logger.info(f"The key:'{key}' was not found in db!")
raise HTTPException(status_code=500, detail="Key on card not found in DB. Please tell an admin about this. KEY={key}")
db.delete(card) db.delete(card)
db.commit() db.commit()
return {"message": "Card deleted successfully"} return {"message": "Card deleted successfully"}
##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)):
cards = db.exec(select(Card).where(Card.group_id == group_id)).all() cards = db.exec(select(Card).where(Card.group_id == group_id)).all()

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,3 +1,5 @@
import logging
logger = logging.getLogger(__name__)
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Depends
from sqlmodel import Session, select from sqlmodel import Session, select
from typing import List from typing import List
@@ -10,7 +12,7 @@ user_router = APIRouter(tags=["Users"])
@user_router.post("/users/", response_model=UserResponse) @user_router.post("/users/", response_model=UserResponse)
def create_user(*, db: Session = Depends(get_session), user: UserCreate, admin: bool = Depends(auth_is_admin)): def create_user(*, db: Session = Depends(get_session), user: UserCreate, admin: bool = Depends(auth_is_admin)):
print("creating user with data ", user) logger.info(f"creating user with data: {user}")
hashed_password = {"passwordhash": get_password_hash(user.password)} hashed_password = {"passwordhash": get_password_hash(user.password)}
db_user = UserDB.model_validate(user, update=hashed_password) db_user = UserDB.model_validate(user, update=hashed_password)
return add_and_refresh(db, db_user) return add_and_refresh(db, db_user)

View File

@@ -1,18 +1,29 @@
import logging
logger = logging.getLogger(__name__)
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_db_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_db_session())
logging.basicConfig(level=logging.INFO)
@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(db=get_db_session())
print("Database created and tables initialized.") logger.info("Database created and tables initialized.")
scanner.start()
yield yield
#scanner.stop()
app = FastAPI(lifespan=lifespan) app = FastAPI(lifespan=lifespan)
@@ -22,4 +33,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):

View File

@@ -1,3 +1,5 @@
import logging
logger = logging.getLogger(__name__)
from typing import Annotated from typing import Annotated
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, HTTPException, Depends, status from fastapi import APIRouter, HTTPException, Depends, status
@@ -12,7 +14,7 @@ import secrets, string
SECRET_KEY = "8b14d0b447bff7efa24d5019cc59a999786e31f6f865173bbd642bf18de5ad85" #Encrypt and change later or store in env file or somehthing SECRET_KEY = "8b14d0b447bff7efa24d5019cc59a999786e31f6f865173bbd642bf18de5ad85" #Encrypt and change later or store in env file or somehthing
ALGORITHM = "HS256" ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30 ACCESS_TOKEN_EXPIRE_MINUTES = 120
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
@@ -83,20 +85,19 @@ def auth_is_admin(
) )
return True return True
def create_first_user(): def create_first_user(db: Session):
print("Checking for admin user") logger.info("Checking for admin user")
with Session(engine) as db: admin_user = db.exec(select(UserDB)).first()
admin_user = db.exec(select(UserDB)).first() if admin_user is None:
if admin_user is None: password = ''.join(secrets.choice(string.digits) for i in range(8))
password = ''.join(secrets.choice(string.digits) for i in range(8)) logger.info(f"Creating first admin user with password: {password}")
print("Creating first admin user with password", password) user = UserDB(
user = UserDB( name="admin",
name="admin", passwordhash=get_password_hash(password),
passwordhash=get_password_hash(password), is_admin=True
is_admin=True )
) return add_and_refresh(db, user)
return add_and_refresh(db, user) logger.info(f"Admin user already exists: {admin_user.name}")
print(f"Admin user already exists: {admin_user.name}")
@token_router.post("/token") @token_router.post("/token")

View File

@@ -13,6 +13,9 @@ def get_session():
with Session(engine) as db: with Session(engine) as db:
yield db yield db
def get_db_session():
return Session(engine)
def add_and_refresh(db: Session, obj): def add_and_refresh(db: Session, obj):
db.add(obj) db.add(obj)
db.commit() db.commit()

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

@@ -0,0 +1,52 @@
import logging
logger = logging.getLogger(__name__)
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
# I think this could also be gpio controlled
#See: https://github.com/technyon/nuki_hub#gpio-lock-control-optional
def openDoor():
global doorIsOpen
doorIsOpen = True
logger.info("Still needs gpio out")
pass
def closeDoor():
global doorIsOpen
doorIsOpen = False
logger.info("Still needs gpio out")
pass
def isDoorOpen():
return doorIsOpen
def checkAccess(uuid: str, db: 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:
logger.info(f"checking auth: {auth.name}")
for timetable in auth.timetables:
logger.info(f" checking timetable {timetable.id}")
logger.info(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)
logger.info(f" comparing time: Start:{starttime} Current:{current_time} End:{endtime}")
if starttime < current_time < endtime:
logger.info("Access Valid!")
return True
logger.info("No more auths found")
return False
except exc.NoResultFound:
raise Exception("No Access with that key found, this might be a db error")

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

@@ -0,0 +1,298 @@
import logging
logger = logging.getLogger(__name__)
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
from app.services.door import openDoor, closeDoor, isDoorOpen, checkAccess
#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
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)
cardservice = cardrequest.waitforcard()
cardservice.connection.connect()
return cardservice
def readFileOnCard(desfire: DESFire):
#create keys
#desfire = DESFire(PCSCDevice(cardservice.connection.component))
aes_keysettings = KeySettings(key_type=DESFireKeyType.DF_KEY_AES)
keysettings = desfire.get_key_setting()
desKey = DESFireKey(keysettings, "00" * 8)
# Get real UID
desfire.authenticate(0x0, desKey)
#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
#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)
rdata = desfire.read_file_data(MIFARE_ENCRYPTED_FILE_ID, file_data)
#convert list of int to str
rdata = to_hex_string(rdata).replace(" ", "").lower()
logger.info(f"Data on card: {rdata}")
return rdata
def DeleteCard():
try:
checkForKey()
from app.main import scanner as scannerThread
scannerThread.stop()
cardservice = getCardService(15)
# Create Desfire object
desfire = DESFire(PCSCDevice(cardservice.connection.component))
rdata = readFileOnCard(desfire=desfire)
# Create Key objects
aes_keysettings = KeySettings(key_type=DESFireKeyType.DF_KEY_AES)
des_keysettings = KeySettings(key_type=DESFireKeyType.DF_KEY_2K3DES)
desKey = DESFireKey(des_keysettings, "00" * 8)
aes_master_key = DESFireKey(aes_keysettings, MIFARE_APP_MASTER_KEY)
aes_null_key = DESFireKey(aes_keysettings, "00" * 16)
desfire.select_application(0x0)
try:
try:
logger.info("Auth1")
desfire.authenticate(0x0, desKey)
except:
logger.info("Auth2")
desfire.authenticate(0x0, aes_null_key)
except:
logger.info("Auth3")
desfire.authenticate(0x0, aes_master_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)
logger.info("App deleted!")
except Exception:
pass
scannerThread.start()
return rdata
except(Exception, AssertionError) 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()
cardservice = getCardService(20)
desfire = DESFire(PCSCDevice(cardservice.connection.component))
# Create Key objects
aes_keysettings = KeySettings(key_type=DESFireKeyType.DF_KEY_AES)
aes_null_key = DESFireKey(aes_keysettings, "00" * 16)
aes_master_key = DESFireKey(aes_keysettings, MIFARE_APP_MASTER_KEY)
desKey = DESFireKey(desfire.get_key_setting(), "00" * 8)
# Authenticate with default DES key
logger.info("Authenticating with default DES key...")
desfire.authenticate(0x0, desKey)
#get uid
uid = desfire.get_real_uid()
# Set default key
logger.info("Setting default key...")
desfire.change_default_key(aes_null_key, 0x0)
# Create application
logger.info("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)
logger.info(" - Application created successfully.")
# Select application
desfire.select_application(MIFARE_APP_ID)
#recreate key object
desfire.authenticate(0x0, aes_null_key)
desfire.change_key(0x0, aes_null_key, aes_master_key, 0x1)
logger.info("new key auth")
desfire.authenticate(0x0, aes_master_key)
aes_null_key = DESFireKey(aes_keysettings, "00" * 16)
#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)
logger.info("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)
logger.info("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)
logger.info("Create encrypted file containing key...")
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)
file_data = desfire.get_file_settings(MIFARE_ENCRYPTED_FILE_ID)
logger.info("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))
logger.info("Reading from encrypted file...")
rdata = desfire.read_file_data(MIFARE_ENCRYPTED_FILE_ID, file_data)
assert rdata == get_list(key)
logger.info(" - 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:
self._check_db(card_content)
time.sleep(5)
logger.debug("READY after success")
else:
time.sleep(0.1)
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):
time.sleep(0.5)
try:
cardservice = getCardService(3)
except CardRequestTimeoutException:
logger.debug("No tag detected within the timeout.")
return
# Create Desfire object
desfire = DESFire(PCSCDevice(cardservice.connection.component))
try:
rdata = readFileOnCard(desfire=desfire)
return rdata
except Exception as e:
logger.error(f"something went wrong: {e}")
time.sleep(5)
def _check_db(self, key):
check = checkAccess(key, self.db)
if check == True:
openDoor()
else:
logger.info("Access denied!")

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

@@ -9,12 +9,13 @@ dependencies = [
"sqlmodel>=0.0.38", "sqlmodel>=0.0.38",
"poetry>=2.3.4", "poetry>=2.3.4",
"python-desfire", "python-desfire",
"paho-mqtt>=2.1.0",
"pyjwt[crypto]>=2.12.1", "pyjwt[crypto]>=2.12.1",
"pwdlib[argon2]>=0.3.0", "pwdlib[argon2]>=0.3.0",
"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 +23,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

@@ -4,13 +4,6 @@ def test_app_startup(client):
# Application should respond (even if it's a 404) # Application should respond (even if it's a 404)
assert response.status_code in [404, 200] assert response.status_code in [404, 200]
def test_health_check(client):
"""Test basic health check endpoint if it exists."""
# Note: This would require adding a health check endpoint
pass
def test_router_includes(): def test_router_includes():
"""Test that all routers are included in the app.""" """Test that all routers are included in the app."""
from app.main import app from app.main import app

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():

View File

@@ -75,8 +75,9 @@ def test_assign_already_assigned_access_auth(client, auth_headers, test_group, t
f"/aa/assign/{test_group.id}/{test_aa.id}", f"/aa/assign/{test_group.id}/{test_aa.id}",
headers=auth_headers headers=auth_headers
) )
# According to the code, this returns 200 with "already assigned" message # According to the code, this returns 409 with "already assigned" message
assert response.status_code == 200 assert response.status_code == 409
assert "already assigned" in response.json()["detail"].lower()
def test_unassign_access_auth_from_group(client, auth_headers, test_group, test_aa): def test_unassign_access_auth_from_group(client, auth_headers, test_group, test_aa):
@@ -147,6 +148,11 @@ def test_update_access_auth_with_timetables(client, auth_headers, test_aa):
headers=auth_headers headers=auth_headers
) )
assert response.status_code == 200 assert response.status_code == 200
jresponse = response.json()
assert len(jresponse["timetables"]) == 1
assert jresponse["timetables"][0]["weekday"] == 5
assert jresponse["timetables"][0]["starttime"] == "10:00:00"
assert jresponse["timetables"][0]["duration"] == 120
def test_update_nonexistent_access_auth(client, auth_headers): def test_update_nonexistent_access_auth(client, auth_headers):

View File

@@ -131,7 +131,6 @@ def test_auth_is_admin(db_session, admin_user, regular_user):
def test_create_first_user(db_session): def test_create_first_user(db_session):
"""Test automatic creation of first admin user.""" """Test automatic creation of first admin user."""
#Currently broken because this uses the prod db because of how i wrote the create_first_user function
# Clear any existing users # Clear any existing users
from sqlmodel import select from sqlmodel import select
db_session.exec(select(UserDB)).all() db_session.exec(select(UserDB)).all()
@@ -140,7 +139,7 @@ def test_create_first_user(db_session):
db_session.commit() db_session.commit()
# Create first user # Create first user
result = create_first_user() result = create_first_user(db=db_session)
assert result is not None assert result is not None
assert result.name == "admin" assert result.name == "admin"
assert result.is_admin is True assert result.is_admin is True
@@ -151,7 +150,7 @@ def test_create_first_user(db_session):
assert user.is_admin is True assert user.is_admin is True
# Test that it doesn't create another admin if one exists # Test that it doesn't create another admin if one exists
second_result = create_first_user() second_result = create_first_user(db=db_session)
assert second_result is None # Should print "Admin user already exists" assert second_result is None # Should print "Admin user already exists"

View File

@@ -1,41 +1,6 @@
import pytest import pytest
from fastapi import status from fastapi import status
def test_add_card(client, auth_headers, test_group):
"""Test adding a card to a group."""
response = client.post(f"/cards/{test_group.id}", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "id" in data
assert "uuid" in data
assert data["group_id"] == test_group.id
assert len(data["uuid"]) > 0 # UUID should be generated
def test_add_card_to_nonexistent_group(client, auth_headers):
"""Test adding a card to a non-existent group."""
response = client.post("/cards/99999", headers=auth_headers)
# This might succeed and create a card with a non-existent group_id
# or fail depending on foreign key constraints
# For now, let's assume it might fail
# assert response.status_code == 404
def test_delete_card(client, auth_headers, test_card):
"""Test deleting a card."""
response = client.delete(f"/cards/{test_card.id}", headers=auth_headers)
assert response.status_code == 200
assert "deleted successfully" in response.json()["message"].lower()
def test_delete_nonexistent_card(client, auth_headers):
"""Test deleting a non-existent card."""
response = client.delete("/cards/99999", headers=auth_headers)
assert response.status_code == 404
def test_get_cards_for_group(client, auth_headers, test_group, test_card): def test_get_cards_for_group(client, auth_headers, test_group, test_card):
"""Test getting all cards for a group.""" """Test getting all cards for a group."""
response = client.get(f"/cards/{test_group.id}", headers=auth_headers) response = client.get(f"/cards/{test_group.id}", headers=auth_headers)

View File

@@ -0,0 +1,61 @@
import pytest
import datetime
from app.services.door import checkAccess
from app.model.models import Card, GroupDB, AccessAuthorizationDB, Timetable
def test_check_access_with_valid_timetable(db_session):
# Setup: create card with valid access
group = GroupDB(name="Test Group")
db_session.add(group)
db_session.commit()
card = Card(uuid="test-uuid-123", group_id=group.id)
db_session.add(card)
timetable = Timetable(
weekday=datetime.datetime.weekday(datetime.date.today()),
starttime=datetime.datetime.now().time(),
duration=120 # 2 hours
)
db_session.add(timetable)
aa = AccessAuthorizationDB(name="Test AA", is_active=True)
db_session.add(aa)
aa.timetables = [timetable]
group.accessauths = [aa]
db_session.commit()
# Test: access should be granted within time window
result = checkAccess("test-uuid-123", db_session)
assert result == True
def test_check_access_outside_hours(db_session):
# Test when current time is outside valid hours
group = GroupDB(name="Test Group")
db_session.add(group)
db_session.commit()
card = Card(uuid="test-uuid-123", group_id=group.id)
db_session.add(card)
timetable = Timetable(
weekday=datetime.datetime.weekday(datetime.date.today()),
starttime=datetime.time(1, 0),
duration=1 # 2 hours
)
db_session.add(timetable)
aa = AccessAuthorizationDB(name="Test AA", is_active=True)
db_session.add(aa)
aa.timetables = [timetable]
group.accessauths = [aa]
db_session.commit()
result = checkAccess("test-uuid-123", db_session)
assert result == False
def test_check_access_invalid_card(db_session):
# Should raise exception for non-existent card
with pytest.raises(Exception):
checkAccess("non-existent-uuid", db_session)

37
uv.lock generated
View File

@@ -609,28 +609,30 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "fastapi", extra = ["standard"] }, { name = "fastapi", extra = ["standard"] },
{ name = "paho-mqtt" },
{ 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" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.135.3" },
{ name = "paho-mqtt", specifier = ">=2.1.0" },
{ 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" },
] ]
@@ -948,15 +950,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
] ]
[[package]]
name = "paho-mqtt"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
]
[[package]] [[package]]
name = "pbs-installer" name = "pbs-installer"
version = "2026.4.7" version = "2026.4.7"
@@ -1230,6 +1223,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 +1535,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"