diff --git a/alembic.ini b/alembic.ini index e0a6d6d..c08abc0 100644 --- a/alembic.ini +++ b/alembic.ini @@ -11,7 +11,7 @@ script_location = %(here)s/pillar_tool/db/migrations # Uncomment the line below if you want the files to be prepended with date and time # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file # for all available tokens -# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s # sys.path path, will be prepended to sys.path if present. # defaults to the current working directory. for multiple paths, the path separator diff --git a/pillar_tool/__init__.py b/pillar_tool/__init__.py index 9acf1d9..e69de29 100644 --- a/pillar_tool/__init__.py +++ b/pillar_tool/__init__.py @@ -1,46 +0,0 @@ - -# load config so everything else can work -from pillar_tool.util import load_config, config -load_config() - -from contextlib import asynccontextmanager - -from fastapi import FastAPI -from starlette.middleware.authentication import AuthenticationMiddleware -from starlette.requests import Request -from fastapi.responses import HTMLResponse, PlainTextResponse -from starlette.responses import JSONResponse - -from pillar_tool.middleware.basicauth_backend import BasicAuthBackend -from pillar_tool.db import run_db_migrations - - -@asynccontextmanager -async def app_lifespan(app: FastAPI): - run_db_migrations() - - yield - - -def on_auth_error(request: Request, exc: Exception): - response = PlainTextResponse(str(exc), status_code=401) - response.headers["WWW-Authenticate"] = "Basic" - - return response - -def on_general_error(request: Request, exc: Exception): - print("wtf?") - response = PlainTextResponse(str(exc), status_code=500) - -app = FastAPI(lifespan=app_lifespan) -app.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend(), on_error=on_auth_error) -app.exception_handler(Exception)(on_general_error) - - -@app.get("/") -async def root(): - return {"message": "Hello World"} - -@app.get("/pillar/{host}") -async def pillar(host: str): - return JSONResponse(content=collect_pillar_data(host)) diff --git a/pillar_tool/__pycache__/__init__.cpython-313.pyc b/pillar_tool/__pycache__/__init__.cpython-313.pyc index 42b00d6..272a76d 100644 Binary files a/pillar_tool/__pycache__/__init__.cpython-313.pyc and b/pillar_tool/__pycache__/__init__.cpython-313.pyc differ diff --git a/pillar_tool/db/__init__.py b/pillar_tool/db/__init__.py index f2c670d..cf4f59d 100644 --- a/pillar_tool/db/__init__.py +++ b/pillar_tool/db/__init__.py @@ -1,26 +1 @@ -from os import lockf, F_LOCK -from os.path import dirname, realpath - -from alembic.config import Config -from alembic.command import upgrade, check, util -from pillar_tool.util import config - - -from .models import * - -cfg = config() -user = cfg.db.user -password = cfg.db.password -host = cfg.db.host -port = cfg.db.port -database = cfg.db.database -alembic_cfg = Config() -alembic_cfg.set_main_option('script_location', f'{dirname(realpath(__file__))}/migrations') -alembic_cfg.set_main_option('sqlalchemy.url', f'postgresql://{user}:{password}@{host}:{port}/{database}') -alembic_cfg.set_main_option('prepend_sys_path', '.') - - -def run_db_migrations(): - with open(realpath(__file__), 'a') as f: - lockf(f.fileno(), F_LOCK, 0) - upgrade(config=alembic_cfg, revision='head') +from .models import * \ No newline at end of file diff --git a/pillar_tool/db/base_model.py b/pillar_tool/db/base_model.py new file mode 100644 index 0000000..ee9c6c2 --- /dev/null +++ b/pillar_tool/db/base_model.py @@ -0,0 +1,4 @@ +from sqlalchemy.orm import declarative_base + + +Base = declarative_base() \ No newline at end of file diff --git a/pillar_tool/db/database.py b/pillar_tool/db/database.py index 3922656..a424beb 100644 --- a/pillar_tool/db/database.py +++ b/pillar_tool/db/database.py @@ -1,9 +1,32 @@ from sqlalchemy import create_engine -from sqlalchemy.orm import Session, sessionmaker, declarative_base -from sqlalchemy.sql.annotation import Annotated +from sqlalchemy.orm import sessionmaker from pillar_tool.util import config +from os import lockf, F_LOCK +from os.path import dirname, realpath + +from alembic.config import Config +from alembic.command import upgrade, check, util +from pillar_tool.util import config + +def run_db_migrations(): + cfg = config() + user = cfg.db.user + password = cfg.db.password + host = cfg.db.host + port = cfg.db.port + database = cfg.db.database + alembic_cfg = Config() + alembic_cfg.set_main_option('script_location', f'{dirname(realpath(__file__))}/migrations') + alembic_cfg.set_main_option('sqlalchemy.url', f'postgresql://{user}:{password}@{host}:{port}/{database}') + alembic_cfg.set_main_option('prepend_sys_path', '.') + + with open(realpath(__file__), 'a') as f: + lockf(f.fileno(), F_LOCK, 0) + upgrade(config=alembic_cfg, revision='head') + + cfg = config() SessionLocal = sessionmaker( @@ -14,20 +37,7 @@ SessionLocal = sessionmaker( ) ) -Base = declarative_base() - def get_connection(): - session = SessionLocal() - # noinspection PyBroadException - try: - yield session - except: - session.rollback() - session.close() - else: - session.commit() - session.close() + return SessionLocal() -DB = Annotated[Session, get_connection()] - diff --git a/pillar_tool/db/migrations/env.py b/pillar_tool/db/migrations/env.py index 11c0bc6..9a1312d 100644 --- a/pillar_tool/db/migrations/env.py +++ b/pillar_tool/db/migrations/env.py @@ -1,4 +1,5 @@ import tomllib +from inspect import stack from logging.config import fileConfig from sqlalchemy import engine_from_config @@ -6,7 +7,6 @@ from sqlalchemy import pool from alembic import context -from pillar_tool.db.database import Base # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -21,6 +21,7 @@ if config.config_file_name is not None: # for 'autogenerate' support # from myapp import mymodel # target_metadata = mymodel.Base.metadata +from pillar_tool.db.base_model import Base target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, diff --git a/pillar_tool/db/migrations/versions/e1f390264396_basic_user_setup.py b/pillar_tool/db/migrations/versions/2025_12_24_1227-e1f390264396_basic_user_setup.py similarity index 100% rename from pillar_tool/db/migrations/versions/e1f390264396_basic_user_setup.py rename to pillar_tool/db/migrations/versions/2025_12_24_1227-e1f390264396_basic_user_setup.py diff --git a/pillar_tool/db/migrations/versions/2025_12_27_1152-f6c806bab641_added_email_to_user.py b/pillar_tool/db/migrations/versions/2025_12_27_1152-f6c806bab641_added_email_to_user.py new file mode 100644 index 0000000..c6181da --- /dev/null +++ b/pillar_tool/db/migrations/versions/2025_12_27_1152-f6c806bab641_added_email_to_user.py @@ -0,0 +1,32 @@ +"""added email to user + +Revision ID: f6c806bab641 +Revises: e1f390264396 +Create Date: 2025-12-27 11:52:45.372890 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'f6c806bab641' +down_revision: Union[str, Sequence[str], None] = 'e1f390264396' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('pillar_tool_user', sa.Column('email', sa.String(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('pillar_tool_user', 'email') + # ### end Alembic commands ### diff --git a/pillar_tool/db/migrations/versions/2025_12_27_1158-ae8de58aa10c_username_is_now_unique.py b/pillar_tool/db/migrations/versions/2025_12_27_1158-ae8de58aa10c_username_is_now_unique.py new file mode 100644 index 0000000..ff8b07a --- /dev/null +++ b/pillar_tool/db/migrations/versions/2025_12_27_1158-ae8de58aa10c_username_is_now_unique.py @@ -0,0 +1,32 @@ +"""username is now unique + +Revision ID: ae8de58aa10c +Revises: f6c806bab641 +Create Date: 2025-12-27 11:58:25.636598 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ae8de58aa10c' +down_revision: Union[str, Sequence[str], None] = 'f6c806bab641' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint('pillar_tool_user_unique_constraint_username', 'pillar_tool_user', ['username']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('pillar_tool_user_unique_constraint_username', 'pillar_tool_user', type_='unique') + # ### end Alembic commands ### diff --git a/pillar_tool/db/migrations/versions/2025_12_27_1159-4cc7f4e295f1_added_unique_to_permission_and_role_name.py b/pillar_tool/db/migrations/versions/2025_12_27_1159-4cc7f4e295f1_added_unique_to_permission_and_role_name.py new file mode 100644 index 0000000..af4d9bc --- /dev/null +++ b/pillar_tool/db/migrations/versions/2025_12_27_1159-4cc7f4e295f1_added_unique_to_permission_and_role_name.py @@ -0,0 +1,34 @@ +"""added unique to permission and role name + +Revision ID: 4cc7f4e295f1 +Revises: ae8de58aa10c +Create Date: 2025-12-27 11:59:54.246224 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4cc7f4e295f1' +down_revision: Union[str, Sequence[str], None] = 'ae8de58aa10c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint('pillar_tool_permission_unique_constraint_name', 'pillar_tool_permission', ['name']) + op.create_unique_constraint('pillar_tool_role_unique_constraint_name', 'pillar_tool_role', ['name']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('pillar_tool_role_unique_constraint_name', 'pillar_tool_role', type_='unique') + op.drop_constraint('pillar_tool_permission_unique_constraint_name', 'pillar_tool_permission', type_='unique') + # ### end Alembic commands ### diff --git a/pillar_tool/db/models/user.py b/pillar_tool/db/models/user.py index b934989..4fd4993 100644 --- a/pillar_tool/db/models/user.py +++ b/pillar_tool/db/models/user.py @@ -1,6 +1,6 @@ -from sqlalchemy import Column, String, UUID, ForeignKey +from sqlalchemy import Column, String, UUID, ForeignKey, UniqueConstraint -from pillar_tool.db.database import Base +from pillar_tool.db.base_model import Base @@ -10,17 +10,30 @@ class User(Base): username = Column(String, nullable=False) pw_hash = Column(String, nullable=False) pw_salt = Column(String, nullable=False) + email = Column(String, nullable=True) + + __table_args__ = ( + UniqueConstraint('username', name='pillar_tool_user_unique_constraint_username'), + ) class Role(Base): __tablename__ = 'pillar_tool_role' id = Column(UUID, primary_key=True) name = Column(String, nullable=False) + __table_args__ = ( + UniqueConstraint('name', name='pillar_tool_role_unique_constraint_name'), + ) + class Permission(Base): __tablename__ = 'pillar_tool_permission' id = Column(UUID, primary_key=True) name = Column(String, nullable=False) + __table_args__ = ( + UniqueConstraint('name', name='pillar_tool_permission_unique_constraint_name'), + ) + class RolePermission(Base): __tablename__ = 'pillar_tool_role_permission' role_id = Column(UUID, ForeignKey("pillar_tool_role.id"), primary_key=True) diff --git a/pillar_tool/db/queries/__init__.py b/pillar_tool/db/queries/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pillar_tool/db/queries/auth_queries.py b/pillar_tool/db/queries/auth_queries.py new file mode 100644 index 0000000..31e70bf --- /dev/null +++ b/pillar_tool/db/queries/auth_queries.py @@ -0,0 +1,45 @@ +import pwd +import uuid + +from sqlalchemy import select, insert +from sqlalchemy.orm import Session + +from pillar_tool.db.models.user import User + +from Crypto.Hash import SHA3_256 +from secrets import token_bytes + + + + +def create_user(db: Session, username: str, password: str) -> None: + pw_salt = token_bytes(32).hex() + pw_hash = compute_password_hash(password, pw_salt) + user_id = uuid.uuid4() + + db.execute(insert(User).values(id=user_id, username=username, pw_hash=pw_hash, pw_salt=pw_salt)) + + + +def compute_password_hash(password: str, salt: str) -> str: + full_salted_password = salt + password + digest = SHA3_256.new(full_salted_password.encode('ascii')).digest() + digest_output = digest.hex() + + return digest_output + +def verify_user(db: Session, user: str, password: str) -> User | None: + # noinspection PyTypeChecker + selected_users = db.execute(select(User).where(User.username == user)).fetchall() + + assert len(selected_users) < 2 + if len(selected_users) == 0: + return None + + # get the first user from the result + user: User = selected_users[0][0] + pw_hash = compute_password_hash(password, user.pw_salt) + + if pw_hash == user.pw_hash: + return user + return None \ No newline at end of file diff --git a/pillar_tool/main.py b/pillar_tool/main.py new file mode 100644 index 0000000..bbb1038 --- /dev/null +++ b/pillar_tool/main.py @@ -0,0 +1,70 @@ +# load config so everything else can work +from pillar_tool.util import load_config, config +load_config() + +from starlette.middleware.base import BaseHTTPMiddleware + +from pillar_tool.db.database import get_connection +from pillar_tool.db.queries.auth_queries import create_user + + +from fastapi import FastAPI +from starlette.middleware.authentication import AuthenticationMiddleware +from starlette.requests import Request +from fastapi.responses import HTMLResponse, PlainTextResponse +from starlette.responses import JSONResponse + +from pillar_tool.middleware.basicauth_backend import BasicAuthBackend +from pillar_tool.middleware.db_connection import db_connection_middleware +from pillar_tool.db.database import run_db_migrations + + +# run any pending migrations +run_db_migrations() + +# get a database connection +db = get_connection() + +# create default user if it does not exist +# noinspection PyBroadException +try: + create_user(db, "admin", "admin") +except: + pass + +# commit and close the db +db.commit() +db.close() + + +def on_auth_error(request: Request, exc: Exception): + response = PlainTextResponse(str(exc), status_code=401) + response.headers["WWW-Authenticate"] = "Basic" + + return response + +def on_db_error(request: Request, exc: Exception): + response = PlainTextResponse(str(exc), status_code=500) + + return response + +def on_general_error(request: Request, exc: Exception): + print("wtf?") + response = PlainTextResponse(str(exc), status_code=500) + return response + +app = FastAPI() +app.add_middleware(AuthenticationMiddleware, backend=BasicAuthBackend(), on_error=on_auth_error) +app.add_middleware(BaseHTTPMiddleware, dispatch=db_connection_middleware) +app.exception_handler(Exception)(on_general_error) + + +@app.get("/") +async def root(): + return {"message": "Hello World"} + +@app.get("/pillar/{host}") +async def pillar(req: Request, host: str): + print(req.headers) + #return JSONResponse(content=collect_pillar_data(host)) + return JSONResponse({}) diff --git a/pillar_tool/middleware/__pycache__/basicauth_backend.cpython-313.pyc b/pillar_tool/middleware/__pycache__/basicauth_backend.cpython-313.pyc index db5ed03..7f9c04e 100644 Binary files a/pillar_tool/middleware/__pycache__/basicauth_backend.cpython-313.pyc and b/pillar_tool/middleware/__pycache__/basicauth_backend.cpython-313.pyc differ diff --git a/pillar_tool/middleware/basicauth_backend.py b/pillar_tool/middleware/basicauth_backend.py index 726fe6c..0483b8a 100644 --- a/pillar_tool/middleware/basicauth_backend.py +++ b/pillar_tool/middleware/basicauth_backend.py @@ -1,12 +1,17 @@ import base64 import binascii + import hypercorn.logging from starlette.authentication import AuthenticationBackend, AuthenticationError, AuthCredentials, SimpleUser +from pillar_tool.db.queries.auth_queries import verify_user + class BasicAuthBackend(AuthenticationBackend): + async def authenticate(self, conn): + print("test 2") if "Authorization" not in conn.headers: raise AuthenticationError('No Authorization Header') @@ -21,7 +26,10 @@ class BasicAuthBackend(AuthenticationBackend): username, _, password = decoded.partition(":") - if username == 'admin' and password == 'password': - return AuthCredentials(["authenticated"]), SimpleUser('admin') + user = verify_user(conn.state.db, username, password) + + if user is None: + raise AuthenticationError('Invalid basic auth credentials') + + conn.state.user = user - raise AuthenticationError('Invalid basic auth credentials') diff --git a/pillar_tool/middleware/db_connection.py b/pillar_tool/middleware/db_connection.py new file mode 100644 index 0000000..f471fa7 --- /dev/null +++ b/pillar_tool/middleware/db_connection.py @@ -0,0 +1,28 @@ +from typing import Callable + +from starlette.requests import Request +from starlette.responses import Response + +from pillar_tool.db.database import get_connection + +async def db_connection_middleware(request: Request, call_next: Callable) -> Response: + session = get_connection() + print("test 1") + + request.state.db = session + try: + response: Response = await call_next(request) + if 200 <= response.status_code <= 299: + session.commit() + session.close() + else: + session.rollback() + session.close() + except Exception as e: + session.rollback() + session.close() + raise e + + return response + + diff --git a/pyproject.toml b/pyproject.toml index e9627fb..003afeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "hypercorn>=0.18.0", "jinja2>=3.1.6", "psycopg2>=2.9.11", + "pycryptodome>=3.23.0", "pydantic>=2.12.5", "sqlalchemy>=2.0.45", ] diff --git a/uv.lock b/uv.lock index a6bee09..83c9c4f 100644 --- a/uv.lock +++ b/uv.lock @@ -242,6 +242,7 @@ dependencies = [ { name = "hypercorn" }, { name = "jinja2" }, { name = "psycopg2" }, + { name = "pycryptodome" }, { name = "pydantic" }, { name = "sqlalchemy" }, ] @@ -253,6 +254,7 @@ requires-dist = [ { name = "hypercorn", specifier = ">=0.18.0" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "psycopg2", specifier = ">=2.9.11" }, + { name = "pycryptodome", specifier = ">=3.23.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "sqlalchemy", specifier = ">=2.0.45" }, ] @@ -276,6 +278,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/08/737aa39c78d705a7ce58248d00eeba0e9fc36be488f9b672b88736fbb1f7/psycopg2-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:f10a48acba5fe6e312b891f290b4d2ca595fc9a06850fe53320beac353575578", size = 2803738, upload-time = "2025-10-10T11:10:23.196Z" }, ] +[[package]] +name = "pycryptodome" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/a6/8452177684d5e906854776276ddd34eca30d1b1e15aa1ee9cefc289a33f5/pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef", size = 4921276, upload-time = "2025-05-17T17:21:45.242Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/5d/bdb09489b63cd34a976cc9e2a8d938114f7a53a74d3dd4f125ffa49dce82/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4", size = 2495152, upload-time = "2025-05-17T17:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/7840250ed4cc0039c433cd41715536f926d6e86ce84e904068eb3244b6a6/pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae", size = 1639348, upload-time = "2025-05-17T17:20:23.171Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/991da24c55c1f688d6a3b5a11940567353f74590734ee4a64294834ae472/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477", size = 2184033, upload-time = "2025-05-17T17:20:25.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/16/0e11882deddf00f68b68dd4e8e442ddc30641f31afeb2bc25588124ac8de/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7", size = 2270142, upload-time = "2025-05-17T17:20:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fc/4347fea23a3f95ffb931f383ff28b3f7b1fe868739182cb76718c0da86a1/pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446", size = 2309384, upload-time = "2025-05-17T17:20:30.765Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d9/c5261780b69ce66d8cfab25d2797bd6e82ba0241804694cd48be41add5eb/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265", size = 2183237, upload-time = "2025-05-17T17:20:33.736Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6f/3af2ffedd5cfa08c631f89452c6648c4d779e7772dfc388c77c920ca6bbf/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b", size = 2343898, upload-time = "2025-05-17T17:20:36.086Z" }, + { url = "https://files.pythonhosted.org/packages/9a/dc/9060d807039ee5de6e2f260f72f3d70ac213993a804f5e67e0a73a56dd2f/pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d", size = 2269197, upload-time = "2025-05-17T17:20:38.414Z" }, + { url = "https://files.pythonhosted.org/packages/f9/34/e6c8ca177cb29dcc4967fef73f5de445912f93bd0343c9c33c8e5bf8cde8/pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a", size = 1768600, upload-time = "2025-05-17T17:20:40.688Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/89756b8d7ff623ad0160f4539da571d1f594d21ee6d68be130a6eccb39a4/pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625", size = 1799740, upload-time = "2025-05-17T17:20:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/5d/61/35a64f0feaea9fd07f0d91209e7be91726eb48c0f1bfc6720647194071e4/pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39", size = 1703685, upload-time = "2025-05-17T17:20:44.388Z" }, + { url = "https://files.pythonhosted.org/packages/db/6c/a1f71542c969912bb0e106f64f60a56cc1f0fabecf9396f45accbe63fa68/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27", size = 2495627, upload-time = "2025-05-17T17:20:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4e/a066527e079fc5002390c8acdd3aca431e6ea0a50ffd7201551175b47323/pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843", size = 1640362, upload-time = "2025-05-17T17:20:50.392Z" }, + { url = "https://files.pythonhosted.org/packages/50/52/adaf4c8c100a8c49d2bd058e5b551f73dfd8cb89eb4911e25a0c469b6b4e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490", size = 2182625, upload-time = "2025-05-17T17:20:52.866Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e9/a09476d436d0ff1402ac3867d933c61805ec2326c6ea557aeeac3825604e/pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575", size = 2268954, upload-time = "2025-05-17T17:20:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c5/ffe6474e0c551d54cab931918127c46d70cab8f114e0c2b5a3c071c2f484/pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b", size = 2308534, upload-time = "2025-05-17T17:20:57.279Z" }, + { url = "https://files.pythonhosted.org/packages/18/28/e199677fc15ecf43010f2463fde4c1a53015d1fe95fb03bca2890836603a/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a", size = 2181853, upload-time = "2025-05-17T17:20:59.322Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ea/4fdb09f2165ce1365c9eaefef36625583371ee514db58dc9b65d3a255c4c/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f", size = 2342465, upload-time = "2025-05-17T17:21:03.83Z" }, + { url = "https://files.pythonhosted.org/packages/22/82/6edc3fc42fe9284aead511394bac167693fb2b0e0395b28b8bedaa07ef04/pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa", size = 2267414, upload-time = "2025-05-17T17:21:06.72Z" }, + { url = "https://files.pythonhosted.org/packages/59/fe/aae679b64363eb78326c7fdc9d06ec3de18bac68be4b612fc1fe8902693c/pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886", size = 1768484, upload-time = "2025-05-17T17:21:08.535Z" }, + { url = "https://files.pythonhosted.org/packages/54/2f/e97a1b8294db0daaa87012c24a7bb714147c7ade7656973fd6c736b484ff/pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2", size = 1799636, upload-time = "2025-05-17T17:21:10.393Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, +] + [[package]] name = "pydantic" version = "2.12.5"