From 4f3a632f4b15c1048c6a909d6166e7abfe8d3f32 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Fri, 23 Oct 2026 02:06:55 +0000 Subject: [PATCH] Implement KCA: web reimplementation of XCA PKI workflows - kaya backend with OIDC sessions, API tokens, Postgres/Tortoise models - AES-256-GCM private key storage and full certificate/CSR/CRL domain logic - Sycamore/WASM SPA for vaults, keys, requests, certificates, CRLs and templates - imports/exports, XCA .xdb migration, Docker Compose stack and tests --- .dockerignore | 10 + .gitea/workflows/build.yaml | 43 + .gitignore | 23 + README.md | 210 ++ deploy/k8s/kca.yaml | 132 ++ docker-compose.yml | 104 + server/.env.example | 48 + server/Dockerfile | 87 + server/conftest.py | 12 + server/dev/mockoauth/Dockerfile | 6 + server/dev/mockoauth/config.json | 74 + .../models/0_20261022232629_init.py | 287 +++ server/packages/kca-app/README.md | 5 + server/packages/kca-app/pyproject.toml | 66 + .../packages/kca-app/src/kca/aerich_config.py | 14 + server/packages/kca-app/src/kca/app.py | 110 + server/packages/kca-app/src/kca/auth.py | 192 ++ server/packages/kca-app/src/kca/config.py | 237 ++ server/packages/kca-app/src/kca/http.py | 73 + .../kca-app/src/kca/logging_config.py | 84 + server/packages/kca-app/src/kca/mixin.py | 49 + server/packages/kca-app/src/kca/models.py | 332 +++ server/packages/kca-app/src/kca/openapi.py | 192 ++ server/packages/kca-app/src/kca/pagination.py | 158 ++ server/packages/kca-app/src/kca/py.typed | 0 .../kca-app/src/kca/routes/__init__.py | 1 + .../packages/kca-app/src/kca/routes/auth.py | 39 + .../kca-app/src/kca/routes/certificates.py | 612 ++++++ .../packages/kca-app/src/kca/routes/crls.py | 109 + .../packages/kca-app/src/kca/routes/health.py | 20 + .../kca-app/src/kca/routes/import_.py | 127 ++ .../packages/kca-app/src/kca/routes/keys.py | 225 ++ server/packages/kca-app/src/kca/routes/me.py | 36 + .../kca-app/src/kca/routes/requests.py | 280 +++ .../packages/kca-app/src/kca/routes/search.py | 75 + .../kca-app/src/kca/routes/templates.py | 229 ++ .../packages/kca-app/src/kca/routes/tokens.py | 98 + .../packages/kca-app/src/kca/routes/vaults.py | 247 +++ server/packages/kca-app/src/kca/service.py | 1278 +++++++++++ server/packages/kca-app/src/kca/static.py | 70 + server/packages/kca-app/src/kca/tokens.py | 107 + .../kca-app/src/kca/tortoise_mixin.py | 145 ++ server/packages/kca-core/README.md | 7 + server/packages/kca-core/pyproject.toml | 18 + .../kca-core/src/kca/core/__init__.py | 1 + .../kca-core/src/kca/core/certificates.py | 522 +++++ server/packages/kca-core/src/kca/core/crls.py | 244 +++ .../packages/kca-core/src/kca/core/crypto.py | 83 + .../packages/kca-core/src/kca/core/exports.py | 324 +++ .../kca-core/src/kca/core/extensions.py | 1188 ++++++++++ .../packages/kca-core/src/kca/core/imports.py | 239 ++ server/packages/kca-core/src/kca/core/keys.py | 543 +++++ .../packages/kca-core/src/kca/core/names.py | 260 +++ .../packages/kca-core/src/kca/core/py.typed | 0 .../kca-core/src/kca/core/requests.py | 224 ++ .../kca-core/src/kca/core/templates.py | 583 +++++ .../packages/kca-core/src/kca/core/xca_db.py | 334 +++ server/packages/kca-core/tests/test_core.py | 302 +++ server/packages/kca-core/tests/test_xca_db.py | 135 ++ server/pyproject.toml | 57 + server/tests/__init__.py | 12 + server/tests/helpers/__init__.py | 0 server/tests/helpers/api.py | 17 + server/tests/helpers/asynctest.py | 23 + server/tests/helpers/oidc.py | 35 + server/tests/test_routes_extra.py | 195 ++ server/tests/test_smoke.py | 126 ++ server/uv.lock | 1949 +++++++++++++++++ web/Cargo.lock | 568 +++++ web/Cargo.toml | 46 + web/Trunk.toml | 12 + web/index.html | 23 + web/pwa/icons/icon.svg | 6 + web/pwa/manifest.webmanifest | 18 + web/pwa/sw.js | 26 + web/src/api.rs | 445 ++++ web/src/auth.rs | 56 + web/src/components/mod.rs | 3 + web/src/components/nav.rs | 88 + web/src/components/toast.rs | 20 + web/src/components/ui.rs | 91 + web/src/dom.rs | 119 + web/src/main.rs | 118 + web/src/model.rs | 738 +++++++ web/src/pages/certificate_detail.rs | 421 ++++ web/src/pages/certificate_new.rs | 727 ++++++ web/src/pages/certificates.rs | 204 ++ web/src/pages/crl_detail.rs | 162 ++ web/src/pages/crls.rs | 107 + web/src/pages/dashboard.rs | 206 ++ web/src/pages/import.rs | 207 ++ web/src/pages/key_detail.rs | 208 ++ web/src/pages/key_new.rs | 152 ++ web/src/pages/keys.rs | 119 + web/src/pages/login.rs | 72 + web/src/pages/mod.rs | 21 + web/src/pages/not_found.rs | 19 + web/src/pages/request_detail.rs | 232 ++ web/src/pages/request_new.rs | 322 +++ web/src/pages/requests.rs | 110 + web/src/pages/search.rs | 145 ++ web/src/pages/settings.rs | 220 ++ web/src/pages/template_editor.rs | 422 ++++ web/src/pages/templates.rs | 105 + web/src/pages/tokens.rs | 169 ++ web/src/pages/vault.rs | 173 ++ web/style.css | 232 ++ web/theme.css | 243 ++ web/vendor/bootstrap.min.css | 6 + 109 files changed, 21048 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitea/workflows/build.yaml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 deploy/k8s/kca.yaml create mode 100644 docker-compose.yml create mode 100644 server/.env.example create mode 100644 server/Dockerfile create mode 100644 server/conftest.py create mode 100644 server/dev/mockoauth/Dockerfile create mode 100644 server/dev/mockoauth/config.json create mode 100644 server/migrations/models/0_20261022232629_init.py create mode 100644 server/packages/kca-app/README.md create mode 100644 server/packages/kca-app/pyproject.toml create mode 100644 server/packages/kca-app/src/kca/aerich_config.py create mode 100644 server/packages/kca-app/src/kca/app.py create mode 100644 server/packages/kca-app/src/kca/auth.py create mode 100644 server/packages/kca-app/src/kca/config.py create mode 100644 server/packages/kca-app/src/kca/http.py create mode 100644 server/packages/kca-app/src/kca/logging_config.py create mode 100644 server/packages/kca-app/src/kca/mixin.py create mode 100644 server/packages/kca-app/src/kca/models.py create mode 100644 server/packages/kca-app/src/kca/openapi.py create mode 100644 server/packages/kca-app/src/kca/pagination.py create mode 100644 server/packages/kca-app/src/kca/py.typed create mode 100644 server/packages/kca-app/src/kca/routes/__init__.py create mode 100644 server/packages/kca-app/src/kca/routes/auth.py create mode 100644 server/packages/kca-app/src/kca/routes/certificates.py create mode 100644 server/packages/kca-app/src/kca/routes/crls.py create mode 100644 server/packages/kca-app/src/kca/routes/health.py create mode 100644 server/packages/kca-app/src/kca/routes/import_.py create mode 100644 server/packages/kca-app/src/kca/routes/keys.py create mode 100644 server/packages/kca-app/src/kca/routes/me.py create mode 100644 server/packages/kca-app/src/kca/routes/requests.py create mode 100644 server/packages/kca-app/src/kca/routes/search.py create mode 100644 server/packages/kca-app/src/kca/routes/templates.py create mode 100644 server/packages/kca-app/src/kca/routes/tokens.py create mode 100644 server/packages/kca-app/src/kca/routes/vaults.py create mode 100644 server/packages/kca-app/src/kca/service.py create mode 100644 server/packages/kca-app/src/kca/static.py create mode 100644 server/packages/kca-app/src/kca/tokens.py create mode 100644 server/packages/kca-app/src/kca/tortoise_mixin.py create mode 100644 server/packages/kca-core/README.md create mode 100644 server/packages/kca-core/pyproject.toml create mode 100644 server/packages/kca-core/src/kca/core/__init__.py create mode 100644 server/packages/kca-core/src/kca/core/certificates.py create mode 100644 server/packages/kca-core/src/kca/core/crls.py create mode 100644 server/packages/kca-core/src/kca/core/crypto.py create mode 100644 server/packages/kca-core/src/kca/core/exports.py create mode 100644 server/packages/kca-core/src/kca/core/extensions.py create mode 100644 server/packages/kca-core/src/kca/core/imports.py create mode 100644 server/packages/kca-core/src/kca/core/keys.py create mode 100644 server/packages/kca-core/src/kca/core/names.py create mode 100644 server/packages/kca-core/src/kca/core/py.typed create mode 100644 server/packages/kca-core/src/kca/core/requests.py create mode 100644 server/packages/kca-core/src/kca/core/templates.py create mode 100644 server/packages/kca-core/src/kca/core/xca_db.py create mode 100644 server/packages/kca-core/tests/test_core.py create mode 100644 server/packages/kca-core/tests/test_xca_db.py create mode 100644 server/pyproject.toml create mode 100644 server/tests/__init__.py create mode 100644 server/tests/helpers/__init__.py create mode 100644 server/tests/helpers/api.py create mode 100644 server/tests/helpers/asynctest.py create mode 100644 server/tests/helpers/oidc.py create mode 100644 server/tests/test_routes_extra.py create mode 100644 server/tests/test_smoke.py create mode 100644 server/uv.lock create mode 100644 web/Cargo.lock create mode 100644 web/Cargo.toml create mode 100644 web/Trunk.toml create mode 100644 web/index.html create mode 100644 web/pwa/icons/icon.svg create mode 100644 web/pwa/manifest.webmanifest create mode 100644 web/pwa/sw.js create mode 100644 web/src/api.rs create mode 100644 web/src/auth.rs create mode 100644 web/src/components/mod.rs create mode 100644 web/src/components/nav.rs create mode 100644 web/src/components/toast.rs create mode 100644 web/src/components/ui.rs create mode 100644 web/src/dom.rs create mode 100644 web/src/main.rs create mode 100644 web/src/model.rs create mode 100644 web/src/pages/certificate_detail.rs create mode 100644 web/src/pages/certificate_new.rs create mode 100644 web/src/pages/certificates.rs create mode 100644 web/src/pages/crl_detail.rs create mode 100644 web/src/pages/crls.rs create mode 100644 web/src/pages/dashboard.rs create mode 100644 web/src/pages/import.rs create mode 100644 web/src/pages/key_detail.rs create mode 100644 web/src/pages/key_new.rs create mode 100644 web/src/pages/keys.rs create mode 100644 web/src/pages/login.rs create mode 100644 web/src/pages/mod.rs create mode 100644 web/src/pages/not_found.rs create mode 100644 web/src/pages/request_detail.rs create mode 100644 web/src/pages/request_new.rs create mode 100644 web/src/pages/requests.rs create mode 100644 web/src/pages/search.rs create mode 100644 web/src/pages/settings.rs create mode 100644 web/src/pages/template_editor.rs create mode 100644 web/src/pages/templates.rs create mode 100644 web/src/pages/tokens.rs create mode 100644 web/src/pages/vault.rs create mode 100644 web/style.css create mode 100644 web/theme.css create mode 100644 web/vendor/bootstrap.min.css diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fad8231 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +server/.venv +server/.mypy_cache +server/tests +web/target +web/dist +web/.trunk +**/__pycache__ +*.pyc +*.log diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..0048cab --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,43 @@ +name: CI + +on: + push: + tags: + - 'release/*' + +jobs: + build_and_push_docker_image: + name: "Build and push docker image" + runs-on: hostinger + steps: + - name: Checkout sources + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to Gitea container registry + uses: docker/login-action@v3 + with: + registry: gitea.woggioni.net + username: woggioni-opencode-agent + password: ${{ secrets.PUBLISHER_TOKEN }} + - name: Extract metadata for image tags + id: meta + uses: docker/metadata-action@v5 + with: + images: gitea.woggioni.net/woggioni/kca + flavor: latest=false + tags: | + type=match,pattern=release/(.*),group=1 + type=raw,value=latest + - name: Build and push docker image + uses: docker/build-push-action@v6 + with: + context: . + builder: multiplatform-builder + file: server/Dockerfile + platforms: linux/amd64 + push: true + pull: true + tags: ${{ steps.meta.outputs.tags }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2454177 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Python +__pycache__/ +*.py[cod] +.venv/ +.mypy_cache/ +.pytest_cache/ +*.egg-info/ + +# Rust / Trunk +web/target/ +web/dist/ +web/.trunk/ + +# Environment and local data +.env +*.log +*.xdb + +# Editor +.idea/ +.vscode/ +.DS_Store +.playwright-mcp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d281a8 --- /dev/null +++ b/README.md @@ -0,0 +1,210 @@ +# KCA — web PKI management + +KCA is a browser-based reimplementation of the software-key workflows of +[XCA](https://github.com/chris2511/xca): private keys, PKCS#10 requests, +X.509 certificates, certificate revocation lists and reusable templates, +organised in private **vaults** (each vault is the web equivalent of one XCA +database). + +- **`server/`** — Python + [kaya](https://github.com/woggioni/kaya) backend: + OIDC login, Redis sessions, personal API tokens, Postgres (Tortoise ORM + + aerich migrations), AES-256-GCM private-key encryption, and a complete REST + API. The pure PKI domain logic lives in `packages/kca-core`; the deployable + app is `packages/kca-app`. +- **`web/`** — Rust + [Sycamore](https://sycamore.dev) compiled to WebAssembly + by [Trunk](https://trunkrs.dev), styled with Bootstrap 5.3 (vendored under + `web/vendor/`) and a dark PKI-console theme. + +## Run the whole stack + +```sh +docker compose up --build +``` + +Postgres, Redis, a mock OIDC provider (test users `alice`, `bob`, `carol`, +`dave`; any username works), the database migrator and the app all start +together. The app listens on `http://127.0.0.1:8080`. + +The browser and the app both use the OIDC issuer +`http://mockoauth:8180/kca`, so add a host entry once: + +```sh +echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts +``` + +Then open , click **Sign in with default**, and type +`alice` (no password). The SPA and API are served from the same origin; the +session cookie authenticates both. + +For a real deployment set at least: + +```sh +# 32 random bytes, base64 or hex: +KCA_MASTER_KEY="$(openssl rand -base64 32)" +# Point these at your IdP and public callback URL: +OIDC_0_ISSUER=https://idp.example.com +OIDC_0_CLIENT_ID=kca +OIDC_0_CLIENT_SECRET=... +OIDC_0_REDIRECT_URI=https://kca.example.com/auth/default/callback +``` + +`server/.env.example` documents every setting. + +## What it does + +### Vaults + +Every user owns a private set of vaults. A vault contains keys, certificates, +requests, CRLs and templates; non-owners get `404`. Vaults can be renamed, +deleted and configured from **Settings** (default hash, serial length, +explicit/mandatory DN entries, default key type/size, default validity). + +### Keys + +- Generate RSA (1024–8192), DSA (1024–3072), EC (P-192/P-224/P-256/P-384/P-521, + secp256k1, brainpool) and Ed25519 keys. +- Import PEM/DER public or private keys, PKCS#8, OpenSSH, PKCS#12 and PVK. +- Export PEM public/private, traditional encrypted PEM, PKCS#8 (plain or + encrypted), DER public/private, SSH2 public/private, JWK public/private and + PVK. +- Private keys are stored encrypted with AES-256-GCM under `KCA_MASTER_KEY` + (AAD bound to the key row id). They are never included in normal API + responses; only an explicit export decrypts them. + +### Requests and certificates + +- Create PKCS#10 requests with subject, key, extensions and attributes + (challenge password, unstructured name); verify imported requests. +- Issue self-signed certificates or certificates signed by a CA in the same + vault, from scratch or from a request. Imported CSRs are marked **signed** + once a certificate is issued from them. +- The certificate wizard covers subject entries, validity, basic constraints, + key usage, extended key usage, SAN/IAN, subject/authority key identifiers, + CRL distribution points, authority information access, name constraints, + certificate policies, policy constraints, inhibit-any-policy, OCSP no-check, + TLS feature (OCSP stapling), Netscape legacy extensions and raw + OID/DER extensions. OpenSSL config text is available on export. +- Certificate details show subject/issuer, serial, validity, signatures, + MD5/SHA-1/SHA-256 fingerprints, parsed extensions, OpenSSL validation + errors, purposes and the issuer chain. +- Renew with a new or kept serial, optionally revoke/replace the old + certificate. Revoke with a reason; manage the CA's revocation list manually. +- Transform certificates to a public key, a CSR or a template. +- Export PEM, PEM chain, PEM+key, PEM+encrypted PKCS#8, PKCS#7, PKCS#7 chain, + DER, PKCS#12, PKCS#12 chain, OpenVPN, OpenSSL config, certificate index, + vCalendar, JWK/JWK chain and the CA's OpenVPN tls-auth key. + +### CRLs + +Generate a CRL from a CA's revocation entries with configurable hash, dates, +reason inclusion, AKI and issuer alternative name. Imported CRLs are linked +to their CA and their serials are merged into its revocation list. Export PEM, +DER or vCalendar. + +### Templates + +Built-in **CA**, **TLS server** and **TLS client** templates mirror XCA's +presets. User templates store subject defaults, extension defaults and a +relative validity range. XCA's binary template format is supported for import +and export, and templates can be applied to a certificate/request spec +server-side. + +### Import and migration + +- Paste PEM or upload files: PEM bundles, DER certificates/requests/CRLs/keys, + PKCS#7, PKCS#12, SSH keys and XCA templates. +- Preview imports before committing; duplicates are skipped. +- Import XCA SQLite `.xdb` databases (schema 7/8): software keys, certificates, + requests, CRLs, revocations, authorities and templates. PKCS#11 token items + are skipped, and private keys encrypted with individual passwords are only + imported when the supplied database password decrypts them. + +### Search and API tokens + +Global search covers internal names, comments, subjects, issuers, serials, +fingerprints and extension text. Personal API tokens (`Authorization: Bearer +kca_…`) let scripts use the REST API; tokens act as their owner, are stored +only as SHA-256 digests, and cannot mint/revoke tokens. + +## REST API + +OpenAPI JSON is at `/api/openapi.json`; Swagger UI at `/api/docs`. + +| Area | Endpoints | +|---|---| +| Identity | `GET /api/health`, `GET /api/me`, `GET /api/auth/providers`, `GET/POST/DELETE /api/tokens` | +| Vaults | `GET/POST /api/vaults`, `GET/PATCH/DELETE /api/vaults/{id}`, `GET/PATCH /api/vaults/{id}/settings`, `GET /api/vaults/{id}/summary`, `GET /api/vaults/{id}/options`, `GET /api/vaults/{id}/search` | +| Keys | `GET/POST /api/vaults/{id}/keys`, `GET/PATCH/DELETE .../keys/{key_id}`, `POST .../keys/{key_id}/export` | +| Requests | `GET/POST /api/vaults/{id}/requests`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/transform/open-key`, `POST .../{id}/transform/template` | +| Certificates | `GET/POST /api/vaults/{id}/certificates`, `GET/PATCH/DELETE .../{id}`, `POST .../{id}/export`, `POST .../{id}/renew`, `POST .../{id}/revoke`, `GET/PATCH .../{id}/ca`, `GET/POST .../{id}/revocations`, `PATCH/DELETE .../revocations/{rev_id}`, `POST .../{id}/crl`, `POST .../{id}/tls-auth`, transforms | +| CRLs | `GET /api/vaults/{id}/crls`, `GET/DELETE .../{crl_id}`, `POST .../{crl_id}/export` | +| Templates | `GET/POST /api/vaults/{id}/templates`, `GET/PATCH/DELETE .../{template_id}`, `POST .../{template_id}/apply`, `POST .../{template_id}/export` | +| Import | `POST /api/vaults/{id}/import` (`dry_run`, `selected`, `xca_db`) | + +Errors are always `{"error": "..."}`. Mutating resources carry a `version` +for optimistic concurrency (`409` on stale writes). Export endpoints return +JSON with `content_base64` so the SPA can download or copy the bytes. + +## Development + +Backend (from `server/`, using [uv](https://docs.astral.sh/uv/)): + +```sh +uv sync --package kca-app +uv run python -m unittest discover -s tests -t . +uv run python -m unittest discover -s packages/kca-core/tests +uv run mypy -p kca.core -p kca +``` + +Tests run in-process with in-memory sqlite, the in-memory session store and a +fake OIDC user, exactly like the spendwise reference app. + +Frontend (from `web/`, with the backend running on `:8080`): + +```sh +trunk serve # SPA on http://localhost:8000, /api and /auth proxied +cargo test +``` + +Migrations live in `server/migrations/`. After changing `models.py`: + +```sh +DATABASE_URL=postgres://kca:password@localhost:5432/kca uv run aerich migrate --name describe_change +DATABASE_URL=postgres://kca:password@localhost:5432/kca uv run aerich upgrade +``` + +## Layout + +``` +server/ + packages/kca-core/ # pure PKI domain (keys, names, extensions, certs, CSRs, + # CRLs, templates, imports/exports, .xdb, AES-GCM) + packages/kca-app/ # kaya app, config, Tortoise models, routes, static + migrations/ # aerich schema history + tests/ # app integration suite + dev/mockoauth/ # dev OIDC provider image + test users +web/ + src/pages/ # dashboard, vault, keys, requests, certificates, + # wizard, CRLs, templates, import, search, settings, tokens + src/components/ # nav, toast, shared UI + src/{api,auth,model,dom}.rs + pwa/ # manifest + app-shell assets +``` + +## Limits + +- **Smart cards / PKCS#11 / HSMs are not supported.** A browser app cannot use + a local card reader; server-side PKCS#11 would require a separate HSM + deployment and is deliberately out of scope. +- The desktop command-line interface is replaced by the REST API and personal + access tokens. +- PKCS#12 export uses PBES2/AES via `cryptography`; legacy RC2/3DES PKCS#12 + output is not available. Legacy inputs are imported when the library + supports them. +- XCA's OpenSSL-config "Advanced" text editor is replaced by typed extension + fields plus raw OID/DER rows. OpenSSL config remains an export format. +- The PWA service worker is intentionally not registered: certificate and key + data must never be served stale from a cache. The manifest and icons are + still available for installation. +- Column layouts, window geometry, language packs and other desktop-only XCA + preferences are not part of the web UI. diff --git a/deploy/k8s/kca.yaml b/deploy/k8s/kca.yaml new file mode 100644 index 0000000..9c7a52c --- /dev/null +++ b/deploy/k8s/kca.yaml @@ -0,0 +1,132 @@ +# KCA — Kubernetes deployment sketch (namespace: kca). +# +# Replace every REPLACE_ME. Postgres and the OIDC provider are external; Redis +# is included because sessions are disposable. KCA_MASTER_KEY is the AES key +# protecting private keys at rest: generate it once and never lose it. +# +# kubectl apply -f deploy/k8s/kca.yaml +# kubectl port-forward -n kca svc/kca 8080:80 +--- +apiVersion: v1 +kind: Namespace +metadata: + name: kca +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: kca-config + namespace: kca +data: + REDIS_URL: redis://redis.kca.svc.cluster.local:6379/0 + DATABASE_ENGINE: postgres + DATABASE_HOST: REPLACE_ME + DATABASE_PORT: "5432" + DATABASE_NAME: kca + DATABASE_USER: kca + STATIC_DIR: /app/web/dist + OIDC_0_PROVIDER: default + OIDC_0_CLIENT_ID: kca + OIDC_0_ISSUER: REPLACE_ME + OIDC_0_REDIRECT_URI: REPLACE_ME + OIDC_0_POST_LOGIN_REDIRECT: / + OIDC_0_POST_LOGOUT_REDIRECT: / +--- +apiVersion: v1 +kind: Secret +metadata: + name: kca-secrets + namespace: kca +type: Opaque +stringData: + DATABASE_PASSWORD: REPLACE_ME + OIDC_0_CLIENT_SECRET: REPLACE_ME + # openssl rand -base64 32 + KCA_MASTER_KEY: REPLACE_ME +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + namespace: kca +spec: + replicas: 1 + selector: + matchLabels: + app: kca-redis + template: + metadata: + labels: + app: kca-redis + spec: + containers: + - name: redis + image: redis:8-alpine + ports: + - containerPort: 6379 + resources: + requests: {cpu: 10m, memory: 32Mi} + limits: {memory: 128Mi} +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: kca +spec: + selector: + app: kca-redis + ports: + - port: 6379 + targetPort: 6379 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: kca + namespace: kca +spec: + replicas: 1 + selector: + matchLabels: + app: kca + template: + metadata: + labels: + app: kca + spec: + securityContext: + runAsNonRoot: true + containers: + - name: kca + image: gitea.woggioni.net/woggioni/kca:latest + ports: + - containerPort: 8080 + envFrom: + - configMapRef: + name: kca-config + - secretRef: + name: kca-secrets + readinessProbe: + httpGet: + path: /api/health + port: 8080 + livenessProbe: + httpGet: + path: /api/health + port: 8080 + resources: + requests: {cpu: 100m, memory: 128Mi} + limits: {memory: 512Mi} +--- +apiVersion: v1 +kind: Service +metadata: + name: kca + namespace: kca +spec: + selector: + app: kca + ports: + - port: 80 + targetPort: 8080 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..433ec35 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,104 @@ +services: + postgres: + image: postgres:18-alpine + environment: + POSTGRES_DB: kca + POSTGRES_USER: kca + POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-password} + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U kca"] + interval: 5s + timeout: 3s + retries: 10 + + # Mock OIDC provider (navikt/mock-oauth2-server). Interactive login accepts + # any username; alice, bob, carol and dave carry profile claims. + # The issuer is http://mockoauth:8180/kca, so both the browser and the app + # need the /etc/hosts entry documented in README.md. + mockoauth: + build: ./server/dev/mockoauth + environment: + SERVER_PORT: "8180" + LOG_LEVEL: INFO + ports: + - "8180:8180" + + mockoauth-init: + image: curlimages/curl + depends_on: + - mockoauth + entrypoint: > + /bin/sh -c " + until curl -sf http://mockoauth:8180/kca/.well-known/openid-configuration > /dev/null; do + echo 'waiting for mockoauth...'; sleep 2; + done + " + + redis: + image: redis:8-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + + db-migrate: + build: + context: . + dockerfile: server/Dockerfile + working_dir: /app + command: ["aerich", "upgrade"] + environment: + DATABASE_ENGINE: postgres + DATABASE_HOST: postgres + DATABASE_PORT: "5432" + DATABASE_NAME: kca + DATABASE_USER: kca + DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password} + depends_on: + postgres: + condition: service_healthy + + kca: + build: + context: . + dockerfile: server/Dockerfile + depends_on: + postgres: + condition: service_healthy + db-migrate: + condition: service_completed_successfully + mockoauth-init: + condition: service_completed_successfully + redis: + condition: service_healthy + environment: + DATABASE_ENGINE: postgres + DATABASE_HOST: postgres + DATABASE_PORT: "5432" + DATABASE_NAME: kca + DATABASE_USER: kca + DATABASE_PASSWORD: ${DATABASE_PASSWORD:-password} + OIDC_0_PROVIDER: ${OIDC_0_PROVIDER:-default} + OIDC_0_ISSUER: ${OIDC_0_ISSUER:-http://mockoauth:8180/kca} + OIDC_0_CLIENT_ID: ${OIDC_0_CLIENT_ID:-kca} + OIDC_0_CLIENT_SECRET: ${OIDC_0_CLIENT_SECRET:-dev-secret} + OIDC_0_REDIRECT_URI: ${OIDC_0_REDIRECT_URI:-http://localhost:8080/auth/default/callback} + OIDC_0_POST_LOGIN_REDIRECT: ${OIDC_0_POST_LOGIN_REDIRECT:-http://localhost:8080/} + OIDC_0_POST_LOGOUT_REDIRECT: ${OIDC_0_POST_LOGOUT_REDIRECT:-http://localhost:8080/} + REDIS_URL: redis://redis:6379/0 + # Development master key. Override for any real deployment: + # openssl rand -base64 32 + KCA_MASTER_KEY: ${KCA_MASTER_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=} + MAX_UPLOAD_BYTES: ${MAX_UPLOAD_BYTES:-20971520} + ports: + - "127.0.0.1:${APP_PORT:-8080}:8080" + +volumes: + pgdata: diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..72dcd0b --- /dev/null +++ b/server/.env.example @@ -0,0 +1,48 @@ +# Environment configuration for KCA. Copy to .env or export the variables. + +# --- Database -------------------------------------------------------------- +DATABASE_ENGINE=postgres +DATABASE_HOST=localhost +DATABASE_PORT=5432 +DATABASE_NAME=kca +DATABASE_USER=kca +DATABASE_PASSWORD=password +#DATABASE_OPTIONS= +#DATABASE_URL= + +# --- OIDC ------------------------------------------------------------------ +# Indexed blocks OIDC_0_*, OIDC_1_*, ... with contiguous indices from 0. +OIDC_0_PROVIDER=default +OIDC_0_ISSUER=http://localhost:8180/kca +OIDC_0_CLIENT_ID=kca +OIDC_0_CLIENT_SECRET=dev-secret +OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback +OIDC_0_POST_LOGIN_REDIRECT=http://localhost:8080/ +OIDC_0_POST_LOGOUT_REDIRECT=http://localhost:8080/ + +# --- Sessions -------------------------------------------------------------- +REDIS_URL=redis://localhost:6379/0 + +# --- Private-key encryption ------------------------------------------------ +# REQUIRED in production: a random 32-byte AES-256 key, base64 or hex. +# openssl rand -base64 32 +# When unset, a built-in development key is used (the UI warns about it). +#KCA_MASTER_KEY= + +# --- Application ----------------------------------------------------------- +APP_HOST=0.0.0.0 +APP_PORT=8080 +STATIC_DIR=web/dist +MAX_UPLOAD_BYTES=20971520 +KEYGEN_CONCURRENCY=2 +SESSION_COOKIE_SECURE=false +#LOGGING_CONFIG= + +# --- CORS (optional) ------------------------------------------------------- +#CORS_ALLOW_ORIGINS= +#CORS_ALLOW_ORIGIN_REGEX= +#CORS_ALLOW_METHODS=GET +#CORS_ALLOW_HEADERS= +#CORS_ALLOW_CREDENTIALS=false +#CORS_EXPOSE_HEADERS= +#CORS_MAX_AGE=600 diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..b139052 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,87 @@ +# syntax=docker/dockerfile:1 +# Multi-stage production image for KCA (kaya backend + Sycamore/WASM frontend). +# The build context is the repository root (see ../docker-compose.yml). + +# --- Frontend -------------------------------------------------------------- +FROM rust:1-slim AS web-builder + +ARG TRUNK_VERSION=0.21.14 + +RUN sed -i 's|http://deb.debian.org|https://deb.debian.org|g' \ + /etc/apt/sources.list.d/debian.sources 2>/dev/null || true \ + && apt-get update \ + && apt-get install -y --no-install-recommends curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -sSfL \ + "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ + | tar -xz -C /usr/local/bin \ + && rustup target add wasm32-unknown-unknown + +WORKDIR /web +COPY web/Cargo.toml web/Cargo.lock ./ +COPY web/index.html web/style.css web/theme.css web/Trunk.toml ./ +COPY web/vendor ./vendor +COPY web/pwa ./pwa +COPY web/src ./src +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/web/target \ + trunk build --release --public-url /static/ + +# --- Backend --------------------------------------------------------------- +FROM alpine:3.24 AS builder + +COPY --from=ghcr.io/astral-sh/uv:0.13.1 /uv /uvx /bin/ + +RUN --mount=type=cache,target=/var/cache/apk \ + apk add python3 build-base python3-dev git +RUN git config --global --add safe.directory /repo + +ENV UV_PYTHON_DOWNLOADS=0 \ + UV_PROJECT_ENVIRONMENT=/opt/venv \ + UV_LINK_MODE=copy + +WORKDIR /repo/server +COPY server/uv.lock server/pyproject.toml ./ +COPY server/packages/kca-core/pyproject.toml packages/kca-core/ +COPY server/packages/kca-app/pyproject.toml packages/kca-app/ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-install-workspace --package kca-app --no-dev + +COPY server/packages/ ./packages/ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-editable --package kca-app --no-dev \ + --reinstall-package kca-core --reinstall-package kca-app + +COPY server/migrations ./migrations + +# --- Runtime --------------------------------------------------------------- +FROM alpine:3.24 + +RUN --mount=type=cache,target=/var/cache/apk \ + apk add python3 ca-certificates tzdata \ + && addgroup -S app && adduser -S -G app app + +COPY --from=builder /opt/venv /opt/venv +COPY --from=builder /repo/server/migrations /app/migrations +COPY --from=builder /repo/server/pyproject.toml /app/pyproject.toml +COPY --from=web-builder /web/dist /app/web/dist + +ENV PATH="/opt/venv/bin:$PATH" \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + GRANIAN_HOST=0.0.0.0 \ + GRANIAN_PORT=8080 \ + GRANIAN_INTERFACE=rsgi \ + GRANIAN_STATIC_PATH_ROUTE="/static" \ + GRANIAN_STATIC_PATH_MOUNT="/app/web/dist" \ + GRANIAN_STATIC_PATH_DIR_TO_FILE=index.html \ + STATIC_DIR=/app/web/dist + +USER app +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \ + CMD wget -q -O- http://127.0.0.1:8080/api/health || exit 1 + +CMD ["granian", "kca.app:app"] diff --git a/server/conftest.py b/server/conftest.py new file mode 100644 index 0000000..f22ef93 --- /dev/null +++ b/server/conftest.py @@ -0,0 +1,12 @@ +"""pytest collection hook: mirror tests/__init__.py environment setup.""" +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "sqlite://:memory:") +os.environ.setdefault("OIDC_0_PROVIDER", "default") +os.environ.setdefault("OIDC_0_ISSUER", "http://localhost:8180/kca") +os.environ.setdefault("OIDC_0_CLIENT_ID", "kca") +os.environ.setdefault("OIDC_0_REDIRECT_URI", "http://localhost:8080/auth/default/callback") +os.environ.setdefault("KCA_MASTER_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=") +os.environ.pop("REDIS_URL", None) diff --git a/server/dev/mockoauth/Dockerfile b/server/dev/mockoauth/Dockerfile new file mode 100644 index 0000000..73b3948 --- /dev/null +++ b/server/dev/mockoauth/Dockerfile @@ -0,0 +1,6 @@ +FROM ghcr.io/navikt/mock-oauth2-server:5.0.2 +# The config is COPYed into the image rather than bind-mounted so the +# stack also works against containerized/rootless Docker daemons, where +# bind mounts of the build context are not always visible. +COPY config.json /config.json +ENV JSON_CONFIG_PATH=/config.json diff --git a/server/dev/mockoauth/config.json b/server/dev/mockoauth/config.json new file mode 100644 index 0000000..b041b27 --- /dev/null +++ b/server/dev/mockoauth/config.json @@ -0,0 +1,74 @@ +{ + "interactiveLogin": true, + "tokenCallbacks": [ + { + "issuerId": "kca", + "tokenExpiry": 3600, + "requestMappings": [ + { + "requestParam": "subject", + "match": "alice", + "claims": { + "sub": "alice", + "name": "Alice Anderson", + "given_name": "Alice", + "family_name": "Anderson", + "middle_name": "B.", + "nickname": "ali", + "preferred_username": "alice", + "profile": "https://example.com/alice", + "picture": "https://i.pravatar.cc/128?u=alice", + "website": "https://alice.example.com", + "gender": "female", + "birthdate": "1990-04-12", + "zoneinfo": "Europe/Oslo", + "locale": "en-US", + "updated_at": 1735689600 + } + }, + { + "requestParam": "subject", + "match": "bob", + "claims": { + "sub": "bob", + "name": "Bob Brown", + "given_name": "Bob", + "family_name": "Brown", + "preferred_username": "bob", + "picture": "https://i.pravatar.cc/128?u=bob", + "zoneinfo": "Europe/Oslo", + "locale": "nb-NO", + "updated_at": 1735689600 + } + }, + { + "requestParam": "subject", + "match": "carol", + "claims": { + "sub": "carol", + "name": "Carol", + "preferred_username": "carol", + "locale": "en-GB" + } + }, + { + "requestParam": "subject", + "match": "dave", + "claims": { + "sub": "dave", + "name": "Dave", + "preferred_username": "dave" + } + }, + { + "requestParam": "subject", + "match": ".*", + "claims": { + "sub": "${subject}", + "preferred_username": "${subject}" + } + } + ] + } + ] +} diff --git a/server/migrations/models/0_20261022232629_init.py b/server/migrations/models/0_20261022232629_init.py new file mode 100644 index 0000000..57883ef --- /dev/null +++ b/server/migrations/models/0_20261022232629_init.py @@ -0,0 +1,287 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + CREATE TABLE IF NOT EXISTS "api_token" ( + "id" UUID NOT NULL PRIMARY KEY, + "user_sub" VARCHAR(255) NOT NULL, + "name" VARCHAR(100) NOT NULL, + "token_prefix" VARCHAR(16) NOT NULL, + "token_hash" VARCHAR(64) NOT NULL UNIQUE, + "created_at" TIMESTAMPTZ NOT NULL, + "last_used_at" TIMESTAMPTZ, + "expires_at" DATE +); +CREATE INDEX IF NOT EXISTS "idx_api_token_user_su_7c5de7" ON "api_token" ("user_sub"); +COMMENT ON TABLE "api_token" IS 'Personal access token for programmatic API access.'; +CREATE TABLE IF NOT EXISTS "app_user" ( + "id" UUID NOT NULL PRIMARY KEY, + "sub" VARCHAR(255) NOT NULL UNIQUE, + "display_name" VARCHAR(200) NOT NULL, + "name" VARCHAR(255), + "family_name" VARCHAR(255), + "given_name" VARCHAR(255), + "middle_name" VARCHAR(255), + "nickname" VARCHAR(255), + "preferred_username" VARCHAR(255), + "profile" VARCHAR(2048), + "picture" VARCHAR(2048), + "website" VARCHAR(2048), + "gender" VARCHAR(100), + "birthdate" VARCHAR(32), + "zoneinfo" VARCHAR(100), + "locale" VARCHAR(100), + "updated_at" BIGINT, + "created_at" TIMESTAMPTZ NOT NULL, + "last_seen_at" TIMESTAMPTZ NOT NULL +); +COMMENT ON TABLE "app_user" IS 'One OIDC identity that has logged in at least once.'; +CREATE TABLE IF NOT EXISTS "vault" ( + "id" UUID NOT NULL PRIMARY KEY, + "owner_sub" VARCHAR(255) NOT NULL, + "name" VARCHAR(200) NOT NULL, + "description" TEXT NOT NULL, + "settings" JSONB NOT NULL, + "version" INT NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL, + "updated_at" TIMESTAMPTZ NOT NULL +); +CREATE INDEX IF NOT EXISTS "idx_vault_owner_s_cdc1f8" ON "vault" ("owner_sub"); +COMMENT ON TABLE "vault" IS 'One private XCA database owned by an OIDC user.'; +CREATE TABLE IF NOT EXISTS "key" ( + "id" UUID NOT NULL PRIMARY KEY, + "name" VARCHAR(200) NOT NULL, + "comment" TEXT NOT NULL, + "source" VARCHAR(20) NOT NULL, + "key_type" VARCHAR(20) NOT NULL, + "key_size" INT, + "curve" VARCHAR(100), + "public_der" BYTEA NOT NULL, + "public_key_sha256" VARCHAR(64) NOT NULL, + "private_der" BYTEA, + "private_nonce" BYTEA, + "has_private" BOOL NOT NULL, + "version" INT NOT NULL, + "search_text" TEXT NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL, + "updated_at" TIMESTAMPTZ NOT NULL, + "vault_id" UUID NOT NULL REFERENCES "vault" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_key_vault_i_e0ffb5" UNIQUE ("vault_id", "public_key_sha256") +); +CREATE INDEX IF NOT EXISTS "idx_key_public__eba47e" ON "key" ("public_key_sha256"); +CREATE INDEX IF NOT EXISTS "idx_key_vault_i_a6f27f" ON "key" ("vault_id", "name"); +CREATE INDEX IF NOT EXISTS "idx_key_vault_i_c2e997" ON "key" ("vault_id", "key_type"); +COMMENT ON TABLE "key" IS 'A public or private key item.'; +CREATE TABLE IF NOT EXISTS "request" ( + "id" UUID NOT NULL PRIMARY KEY, + "name" VARCHAR(200) NOT NULL, + "comment" TEXT NOT NULL, + "source" VARCHAR(20) NOT NULL, + "subject_der" BYTEA NOT NULL, + "req_der" BYTEA NOT NULL, + "public_key_der" BYTEA NOT NULL, + "request_sha256" VARCHAR(64) NOT NULL, + "signed" BOOL NOT NULL, + "signed_at" TIMESTAMPTZ, + "attributes" JSONB NOT NULL, + "version" INT NOT NULL, + "search_text" TEXT NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL, + "updated_at" TIMESTAMPTZ NOT NULL, + "key_id" UUID REFERENCES "key" ("id") ON DELETE SET NULL, + "vault_id" UUID NOT NULL REFERENCES "vault" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_request_vault_i_b661f4" UNIQUE ("vault_id", "request_sha256") +); +CREATE INDEX IF NOT EXISTS "idx_request_request_fad061" ON "request" ("request_sha256"); +CREATE INDEX IF NOT EXISTS "idx_request_vault_i_346e22" ON "request" ("vault_id", "name"); +CREATE INDEX IF NOT EXISTS "idx_request_vault_i_2cd9ef" ON "request" ("vault_id", "signed"); +COMMENT ON TABLE "request" IS 'A PKCS#10 certificate signing request item.'; +CREATE TABLE IF NOT EXISTS "certificate" ( + "id" UUID NOT NULL PRIMARY KEY, + "name" VARCHAR(200) NOT NULL, + "comment" TEXT NOT NULL, + "source" VARCHAR(20) NOT NULL, + "serial_hex" VARCHAR(128) NOT NULL, + "subject_der" BYTEA NOT NULL, + "issuer_der" BYTEA NOT NULL, + "subject_hash" VARCHAR(64) NOT NULL, + "issuer_hash" VARCHAR(64) NOT NULL, + "not_before" TIMESTAMPTZ NOT NULL, + "not_after" TIMESTAMPTZ NOT NULL, + "is_ca" BOOL NOT NULL, + "path_length" INT, + "cert_der" BYTEA NOT NULL, + "public_key_der" BYTEA NOT NULL, + "signature_algorithm" VARCHAR(100) NOT NULL, + "fingerprint_md5" VARCHAR(32) NOT NULL, + "fingerprint_sha1" VARCHAR(40) NOT NULL, + "fingerprint_sha256" VARCHAR(64) NOT NULL, + "version" INT NOT NULL, + "search_text" TEXT NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL, + "updated_at" TIMESTAMPTZ NOT NULL, + "issuer_id" UUID REFERENCES "certificate" ("id") ON DELETE SET NULL, + "key_id" UUID REFERENCES "key" ("id") ON DELETE SET NULL, + "request_id" UUID REFERENCES "request" ("id") ON DELETE SET NULL, + "vault_id" UUID NOT NULL REFERENCES "vault" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_certificate_vault_i_842384" UNIQUE ("vault_id", "fingerprint_sha256") +); +CREATE INDEX IF NOT EXISTS "idx_certificate_serial__787fd1" ON "certificate" ("serial_hex"); +CREATE INDEX IF NOT EXISTS "idx_certificate_subject_ed411b" ON "certificate" ("subject_hash"); +CREATE INDEX IF NOT EXISTS "idx_certificate_issuer__b8daa0" ON "certificate" ("issuer_hash"); +CREATE INDEX IF NOT EXISTS "idx_certificate_is_ca_906cf3" ON "certificate" ("is_ca"); +CREATE INDEX IF NOT EXISTS "idx_certificate_fingerp_12be2c" ON "certificate" ("fingerprint_sha256"); +CREATE INDEX IF NOT EXISTS "idx_certificate_vault_i_18b9a0" ON "certificate" ("vault_id", "name"); +CREATE INDEX IF NOT EXISTS "idx_certificate_vault_i_dfb18f" ON "certificate" ("vault_id", "issuer_id"); +CREATE INDEX IF NOT EXISTS "idx_certificate_vault_i_eded8f" ON "certificate" ("vault_id", "not_after"); +COMMENT ON TABLE "certificate" IS 'An X.509 certificate item.'; +CREATE TABLE IF NOT EXISTS "certificate_revocation" ( + "id" UUID NOT NULL PRIMARY KEY, + "serial_hex" VARCHAR(128) NOT NULL, + "revoked_at" TIMESTAMPTZ NOT NULL, + "invalidity_date" TIMESTAMPTZ, + "reason" VARCHAR(50) NOT NULL, + "crl_number" INT NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL, + "ca_id" UUID NOT NULL REFERENCES "certificate" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_certificate_ca_id_1c9aa5" UNIQUE ("ca_id", "serial_hex") +); +CREATE INDEX IF NOT EXISTS "idx_certificate_serial__8fa20f" ON "certificate_revocation" ("serial_hex"); +COMMENT ON TABLE "certificate_revocation" IS 'A serial revoked by a CA (XCA stores revocations on the CA).'; +CREATE TABLE IF NOT EXISTS "crl" ( + "id" UUID NOT NULL PRIMARY KEY, + "name" VARCHAR(200) NOT NULL, + "comment" TEXT NOT NULL, + "source" VARCHAR(20) NOT NULL, + "crl_der" BYTEA NOT NULL, + "crl_hash" VARCHAR(64) NOT NULL, + "crl_number" INT, + "last_update" TIMESTAMPTZ NOT NULL, + "next_update" TIMESTAMPTZ, + "revoked_count" INT NOT NULL, + "signature_algorithm" VARCHAR(100) NOT NULL, + "search_text" TEXT NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL, + "updated_at" TIMESTAMPTZ NOT NULL, + "issuer_id" UUID REFERENCES "certificate" ("id") ON DELETE SET NULL, + "vault_id" UUID NOT NULL REFERENCES "vault" ("id") ON DELETE CASCADE, + CONSTRAINT "uid_crl_vault_i_e5461a" UNIQUE ("vault_id", "crl_hash") +); +CREATE INDEX IF NOT EXISTS "idx_crl_crl_has_c44d61" ON "crl" ("crl_hash"); +CREATE INDEX IF NOT EXISTS "idx_crl_vault_i_6af15c" ON "crl" ("vault_id", "name"); +CREATE INDEX IF NOT EXISTS "idx_crl_vault_i_576344" ON "crl" ("vault_id", "issuer_id"); +COMMENT ON TABLE "crl" IS 'A certificate revocation list item.'; +CREATE TABLE IF NOT EXISTS "template" ( + "id" UUID NOT NULL PRIMARY KEY, + "name" VARCHAR(200) NOT NULL, + "comment" TEXT NOT NULL, + "source" VARCHAR(20) NOT NULL, + "subject" JSONB NOT NULL, + "settings" JSONB NOT NULL, + "version" INT NOT NULL, + "search_text" TEXT NOT NULL, + "created_at" TIMESTAMPTZ NOT NULL, + "updated_at" TIMESTAMPTZ NOT NULL, + "vault_id" UUID NOT NULL REFERENCES "vault" ("id") ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS "idx_template_vault_i_18d8f5" ON "template" ("vault_id", "name"); +COMMENT ON TABLE "template" IS 'An XCA-style template: subject + extension/validity defaults.'; +CREATE TABLE IF NOT EXISTS "authority" ( + "id" UUID NOT NULL PRIMARY KEY, + "crl_expire" TIMESTAMPTZ, + "crl_number" INT NOT NULL, + "crl_days" INT NOT NULL, + "tls_auth_key" BYTEA, + "tls_auth_key_nonce" BYTEA, + "created_at" TIMESTAMPTZ NOT NULL, + "updated_at" TIMESTAMPTZ NOT NULL, + "template_id" UUID REFERENCES "template" ("id") ON DELETE SET NULL, + "certificate_id" UUID NOT NULL UNIQUE REFERENCES "certificate" ("id") ON DELETE CASCADE +); +COMMENT ON TABLE "authority" IS 'CA-specific properties, one row per CA certificate.'; +CREATE TABLE IF NOT EXISTS "aerich" ( + "id" SERIAL NOT NULL PRIMARY KEY, + "version" VARCHAR(255) NOT NULL, + "app" VARCHAR(100) NOT NULL, + "content" JSONB NOT NULL +);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + """ + + +MODELS_STATE = ( + "eJztXW1z2zYS/isc3ZdkLvHZsuS4nZubkRW39SWxM46Sc9vpcCASknCmSJUEbau5/PcDKF" + "J8A19AixLJ7BcnBrEQ/SwEYp9nsfzaW1o6Npyj0YpMrHts9n5UvvZMtMTsP6lrr5QeWq3C" + "K7yBoqnhdUYrotJtt6lDbaRRdmGGDAezJh07mk1WlFj8Q3ofse1YJjIUpGnYcRTPVJlZtr" + "KyrbmNlktEiaaMPl75PY74uLqlsYGJOa8+hGuSP13MbnWO6QLbbKDf/2DNxNTxE3aCX1f3" + "6oxgQ4/hQXQ+gNeu0vXKa/v8+ertT15PfntTVbMMd2mGvVdrurDMbXfXJfoRt+HX5tjENq" + "JYj8BkuobhQxo0be6YNVDbxdtb1cMGHc+Qa3Cwe/+cuabGMVa8T+I/Bv/yby3STVWvbybq" + "p8uJqvZSvuG3kMDab9Isk/uVmJQD9fXbZtwQEK+1xz9g/Mvo9sXp2UsPAsuhc9u76MHV++" + "YZIoo2ph7oIcqug23VcadprMcLZIuxjtokEGc3XAXroCEH7ACnGpDtLdGTamBzThfs1/5w" + "mAP1l9Gthzbr5cFtsS/e5it57V/qb65x2EOYvX8lIA761wVvuE7sHd+T4+MS+LJemfh61+" + "L4equRurLxjDzJ4Jy06yLeZ2XgPstG+0wM9gI5C3moA6vdAF28Ru8N5rNBCZjPBpkw80tx" + "mDUbcyhURNMwv2VXKFliMdRxywTUum96FPynjTOc/YH6jWmsfZfnQD+5+nD5aTL68JF/3N" + "Jx/jQ8/EaTS36l77WuE60vkt+G7SDKf64mvyj8V+W3m+vL5AN322/yW4/fE3KppZrWo4r0" + "yOwMWgPUYl43kENV9oSt4vek7Q48799yUxzfEkcHmOR6Gj+tCBsu089iH8et8jzcKe9yj7" + "H1kUcLs/vITpY3TJF2/4hsXU1dsfpWVt/0pWV/mWxBJpp7MPD75XcXBGkug90mdN0TRXDb" + "i69yQ7hYt8IQbjx67aywRmYsyGIR1wrblGDnlWKZWLGtR4U1KOORovF21of5PB3DVRwDgr" + "iGB3GabaibdUF+pxC1hOdFk58X3Femu5xiO+3lK5NmOzg0SjiYoVTX5u+4rp3fnN/B6/7J" + "4M3g/PRscM66eHe5bXmT4/Or60lqk22oOlo7kpAGJvsD9LQtiFKDbU7Ys029x+s0qhfERP" + "Y6I0JMWCbAna4pdjq1BF28v7mIrT4sGry4uh7d/vriw+juZWwJen9z/XPQP7qyXPw6uRzl" + "uIAtJ6YmeC6UdURoD+6o6A4I5LMXn+4G8u5Kr+j1uCV4vSleF+zS/LuPLL14uTKYJ1S5yC" + "dh9owQqD3LbWHAE1lBw4hUEti05X7Dy0ZCm2JR0vM3jfFPlo3J3HyHN5uGK3YvSLwz8HmQ" + "SWSoVk7asDW8RRs9bmmO5LeWQcD+cEw9ENg9KNef37/vfZOhqIRTPu2LGxNPLPajpCfG8b" + "HaOMsLfZH+osfcMR59Go/eXia88SwSMIqqgAZMgJ5NBCY8XUwFjkzl7mh4/EOUqFMIm4xp" + "xi+/q4DY+7334PnPm3LmHNsrNhRVnQXqD896fyR4v0hv769nlyNNxHFcNmq80bSoimaUtw" + "NreFDWEHISwpyPUjkJ/ZychH46J0GzlktsCnb/E/yURS2FJvtDuXeAaTu5vJvEdvQBkuJI" + "O+geQX7Mg+844I7l2iKqI3tKhxZ7hDscdE9zu9TUzpnZyYntYJsgQ11gqVSbuFX38sZO+u" + "dlEm3659mZNvxaAmp3+l+sUXbvAtI/j8JLGFbk7pq2ah+AvNtsYuQdELcD/KviH0xk2XSz" + "pF33FpzdZ5z5c1YW6oQZIF2MNI+CpnhmySv2ccsOksPtJYPTGkAY7Fbw8tYQnNxoJxNH1Z" + "Bgc2BZBkZm1pLp2yQ3BsyoptVy69897wtubt7HPHlxlQzlPn+4uGQ7Y8+FrBPZMHbpzIIV" + "ootgUU7BnZmukbCqlLHRMH5650kw2K4QYkStYH9bdX+7cqcG0bxMC2kPpG3BD5XjDDI3EX" + "VtrCJjzvNyF0upcENs3gEWbw+nt6JKw1IfyuAuMO0c5qf9EpCf9jMR55eyAXcW6KQq4oFt" + "5yAflJnlg+xJPsid476aVh103xoi7eJI+wHbDr+l8hvGiMX+0ntPWrJVdDCytYVK8ZOUsJ" + "Uw68B6sW9xC5JIs+d4O9MJIYn0e/R6mSRSn1CXTIOJGkECaXz15CGyHJyhBWAZx9LG7I9x" + "qCSecSvANLFL5TcliWjUZpfZb60FNCetOZLU+Kyc5i/BOO0EtTCLNjqnxPmziVV1B5i+24" + "zSyq99IaDhYyQjOzz93N8BpHIJ3y2FNrbhKYGu/wTaAby34UjdhDb+sJY+1xCJWhfE0G0s" + "oF8ufMuf3t1iA1Ex4dLl6ZyYmw+W5qEgOIleEarb7ZgdemAlD+8/Fy67jNzelhklVfImhD" + "FWiUaMZXDgqBjRWPWbDuFa7WBQ5DuYf0Qo/mUtdVhIteM2xeeGlE0OtsIN77GuTNcK4gV/" + "XtyxHw5lj0FHiaxEimUqdIFZj5eCk0XPHEx49miTExPJFE8eOIIjQwc9MgSZ/3vL/Pe/Vh" + "WY37hlB5nf9jK9guRB8wEZRGfPSzWo2Sfja4E5FPFqsr9tjByRGp29eoYWe1RNXdMvFLiv" + "U2rDMgkXw+yEi2H6+CWUS6ujXBqo0N3SI8uo0BqSrbqCQJcopUuIDg7UzPY2FNfiAh+ofF" + "2PvRUC5vyRKLDf0Eo5YbzfoUTMHq3cEcbTikEcmlX0o5xNfvUP/iT0TvRVq/kBgTrU9oDa" + "HlDbI4ok1PZofm0Pr7ix9Emw0AgOICXnd/kqsYb0yfuoTfd41TpeqXMYTqBhhNquWYHNe2" + "dWVQjMhGkHeYGW8ACl2EuT7W4qOjphCix1k/0cSEia5Yo2uZlLZcoOGNTUASo4Z1xKU63j" + "nDEcXoPDa03bHnRYNoDDa13zumDzAIfX4HBQGwCFw0FFoO74cBAcZpHBtsxhlsNInPx4lk" + "Di9E9tZUuc/uGwMhLnpo6VYtnKyiYPXLRkxpniZkHvfFkzUjKr0jsNuKE3P0HhBIUTFE5Q" + "OEHhbJnCuV3BJZCO2nRyAakDZIf8JQA5kzuOmoDIJki9de0HqUm7Nag0Y5uGZv3ssL81rF" + "qFFRIgdlcJV75AotAYUiKKUyL8GKrCtI8bwtuhq057H8cK7+lOmYITKjphgRzVR1PggrxK" + "/gnL2ur5pz2wbWltQX+ozbrTHSLI2yBvN20f2VKhE+Tt79HrZeRtEGNBjG2CYFgsxpaqzx" + "Zqq7srPNbaiKOoRptXFO+ZOHWudqBc1TFJ8TlASyBAR4DMFqEjNR/LCNEf340//e3kOHZ+" + "lmfcMlwUf6hMWVrKNl+kDsovVlKo+WcyKEGfBn26IWw96NNdCHJBn96PdAqvfD80Gcwev/" + "LoR4wA+arIw8swm+GHxAZUYs1PW4IIWyzC+nv29HzPU55CIxCdJESnDWwVWNuYIZxfbg5H" + "m6bmEWV/69QVklr//nRzLXZw3CrpYaJR5X9eCa3a9q5h2Dt1iUGJ6RzxzztE5MtRyg8hkt" + "FCwo18gGQIAXpvD/TeDoTCoPdmz/F2Kn+g936PXi+j98KbIwtWWDjIDNp5s0CFtxw27i2H" + "kIvQbJ19gpcrA3leSwnt22uv8pR2Gu1VLLWbyt149NqhawMrgemPii9hKH9XWHCCTR76/S" + "N4wYniI+0IxPfnjiaQ44s0d5DXQV4HeR3k9SiSIK+3Rl6X4YUjJkAK75wUdjBlf9RciqmP" + "2jTDJfxjO+MS4Ol7wNN34JkKPH32HG8nYws8/ffo9TI8PXDLwC03gQrdzbksNtMXvLo7eS" + "4VOvIH6hD5XCsR+tnx6L8UCeq1v8ojQNkV1Q16FRKgNyZWbq7ejhWiY5NyQpIuEFUWyFEM" + "a85uTSGmwhoMjByq8Poeadqz4hjFZCdwmwflNh13KsUDbbrvJj4oRnh/vOZwWIb8GQ6z2R" + "9+LR4S6MRZGWityvLHSTvgkcvxyHvl6Rv2ANvDdJ6hJTHkZ3PCDMAuBfacPGBTGuu4FUBd" + "Cuol0XUDS2OdMAOwS4FtEu1eep2O2ADMpWBe2XiGbRvrXpggC7jYGqAvCb01I4Yk3luTDo" + "J8PDgvtdcbnOds9vjFBM5E4++mk8I5NAGcy+L8iKcOEZWqzMY5YgI4l8WZYSI8h5yz29ta" + "dA/lWqpuT4lNF+IXymajHDPqHtCn/RI4n/YzYeaX4ij/ZZmYmDNLBuSoTfcwrmUyG5aG5L" + "YZoQVAXAriPHX3gswzM1Rytd02v2Dih37/9PRN//j07Hw4ePNmeH68zVdJX8pLXLm4+pnn" + "rsRcIXgNBeRUdExdL5NTYSBe0wNjs4Lfk7bg+aZ4PiuvIpUGkC1Z16nFfvGdlBJjtzkE2W" + "rsNl2hnBQbvEnwbjxSuC43RQ5WrEcT68p0rSBzo7Ny5kUsw0ragwS7s69ePRIsd52tSgqx" + "MaPulT2qh3uGczy16q/RO0vBnJ17nDCD3GP58zxwrAGONXT6WANEgtlQtzMegOz679HrO4" + "gCD1/EoWlzITN5Ob6CGs8Fya6t0uehwbnH62eCU67kSivB2fe7SFoJUlAJ5JkoReufdAWm" + "Omm7EbaJtugJeDv/yqvcYxRhnyLmLhuGHfNrmfGBkF4ThAb+xqq5ifc7CQ2y6bTMyCub6M" + "kOvTrB9dTBpfEvlQTCfvcOoluL8Mw+kQor4mTzORGT/dM59T9WaiVuDqpBffs/ebmCjQ==" +) diff --git a/server/packages/kca-app/README.md b/server/packages/kca-app/README.md new file mode 100644 index 0000000..186cbb5 --- /dev/null +++ b/server/packages/kca-app/README.md @@ -0,0 +1,5 @@ +# kca-app + +The deployable KCA application: environment-driven settings, kaya app assembly +(sessions, OIDC, Tortoise, OpenAPI, CORS, optional OpenTelemetry), the HTTP +API, Tortoise models, aerich migrations, and the SPA/PWA shell hosting. diff --git a/server/packages/kca-app/pyproject.toml b/server/packages/kca-app/pyproject.toml new file mode 100644 index 0000000..41b8535 --- /dev/null +++ b/server/packages/kca-app/pyproject.toml @@ -0,0 +1,66 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "kca-app" +version = "0.1.0" +description = "KCA: kaya application wiring, Tortoise models, API and SPA hosting" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "kca-core", + "kaya-core>=0.0.7", + "kaya-cors", + "kaya-session", + "kaya-session-redis", + "kaya-oidc>=0.0.7", + "kaya-openapi", + "kaya-rsgi", + "granian>=2.0", + "tortoise-orm>=0.25", + "asyncpg", + "aerich", + "httpx", + "PyJWT[crypto]", + "PyYAML", + "redis", + "cryptography>=44", + "pyOpenSSL>=25", + "paramiko>=3.5", +] + +[project.optional-dependencies] +otel = [ + "kaya-otel>=0.0.7", + "opentelemetry-instrumentation-asyncpg>=0.66b0", + "opentelemetry-instrumentation-redis>=0.66b0", + "opentelemetry-instrumentation-system-metrics>=0.66b0", +] + +[dependency-groups] +dev = [ + "mypy", + "types-PyYAML", +] + +[tool.setuptools.packages.find] +where = ["src"] +namespaces = true + +[tool.mypy] +python_version = "3.12" +ignore_missing_imports = true +plugins = [] + +[[tool.mypy.overrides]] +module = "kca.models" +disable_error_code = ["attr-defined"] + +[[tool.mypy.overrides]] +module = "kca.service" +disable_error_code = ["attr-defined", "assignment", "index", "arg-type"] + +[[tool.mypy.overrides]] +module = "kca.routes.*" +disable_error_code = ["attr-defined", "assignment", "index", "arg-type"] diff --git a/server/packages/kca-app/src/kca/aerich_config.py b/server/packages/kca-app/src/kca/aerich_config.py new file mode 100644 index 0000000..be06a4f --- /dev/null +++ b/server/packages/kca-app/src/kca/aerich_config.py @@ -0,0 +1,14 @@ +"""Tortoise ORM configuration consumed by the aerich CLI.""" +from __future__ import annotations + +from .config import settings + +TORTOISE_ORM = { + "connections": {"default": settings.database_url}, + "apps": { + "models": { + "models": ["kca.models", "aerich.models"], + "default_connection": "default", + } + }, +} diff --git a/server/packages/kca-app/src/kca/app.py b/server/packages/kca-app/src/kca/app.py new file mode 100644 index 0000000..d61e253 --- /dev/null +++ b/server/packages/kca-app/src/kca/app.py @@ -0,0 +1,110 @@ +"""Application entry point. + +Assembles the kaya app with sessions, OIDC, Tortoise, OpenAPI, the KCA API +and the SPA shell. Optional CORS mirrors spendwise's environment-driven +configuration. +""" +from __future__ import annotations + +from logging import getLogger +from typing import Any, Dict, Optional + +from kaya.core import KayaApp, KayaMixin +from kaya.cors import CorsMixin +from kaya.oidc import OIDCConfig, OIDCProviders +from kaya.session import SessionMixin +from redis.asyncio import Redis + +from .config import Settings, settings +from .logging_config import configure_logging +from .mixin import Kca, KcaMixin +from .openapi import SCHEMAS, SECURITY_SCHEMES, KcaOpenAPI +from .tortoise_mixin import TortoiseMixin + +configure_logging(settings.logging_config) +log = getLogger(__name__) + + +def cors_mixin_from_settings(settings: Settings) -> Optional[CorsMixin]: + if settings.cors_allow_origins is None and settings.cors_allow_origin_regex is None: + return None + return CorsMixin( + allow_origins=settings.cors_allow_origins or (), + allow_origin_regex=settings.cors_allow_origin_regex, + allow_methods=settings.cors_allow_methods or ("GET",), + allow_headers=settings.cors_allow_headers or (), + allow_credentials=settings.cors_allow_credentials, + expose_headers=settings.cors_expose_headers or (), + max_age=settings.cors_max_age, + ) + + +if settings.master_key_is_dev: + log.warning( + "KCA_MASTER_KEY is unset: using the built-in development key. " + "Set a random 32-byte base64 key in production." + ) + +if settings.redis_url is not None: + from kaya.session.redis import RedisSessionStore + + session_store: Any = RedisSessionStore(Redis.from_url(settings.redis_url)) + log.info("using the Redis session store") +else: + from kaya.session import InMemorySessionStore + + session_store = InMemorySessionStore() + log.info("REDIS_URL unset: using the in-memory session store") + +session_mixin = SessionMixin(session_store, secure=settings.session_cookie_secure) +oidc = OIDCProviders( + [ + OIDCConfig( + provider=provider.provider, + issuer=provider.issuer, + client_id=provider.client_id, + client_secret=provider.client_secret, + redirect_uri=provider.redirect_uri, + post_login_redirect=provider.post_login_redirect, + post_logout_redirect=provider.post_logout_redirect, + scopes=("openid", "profile"), + fetch_userinfo=True, + ) + for provider in settings.oidc_providers + ], + session=session_mixin, +) +log.info("OIDC providers: %s", ", ".join(p.provider for p in settings.oidc_providers)) + +openapi_mixin = KcaOpenAPI( + title="KCA", + version="0.1.0", + description="Key and certificate authority API (XCA web reimplementation)", + spec_path="/api/openapi.json", + docs_path="/api/docs", + components={"schemas": SCHEMAS, "securitySchemes": SECURITY_SCHEMES}, +) +tortoise_mixin = TortoiseMixin( + database_url=settings.database_url, + models_modules=["kca.models"], + skip_paths=frozenset({"/api/health", "/api/docs", "/api/openapi.json"}), +) +kca = Kca(oidc=oidc) + +mixins: list[KayaMixin] = [ + session_mixin, + oidc, + tortoise_mixin, + openapi_mixin, + KcaMixin(kca), +] + +cors_mixin = cors_mixin_from_settings(settings) +if cors_mixin is not None: + mixins.insert(0, cors_mixin) + log.info("CORS enabled: origins=%s", settings.cors_allow_origins) + +app = KayaApp(mixins=mixins) + +# Register the SPA shell and PWA entry points. +from . import static # noqa: E402,F401 diff --git a/server/packages/kca-app/src/kca/auth.py b/server/packages/kca-app/src/kca/auth.py new file mode 100644 index 0000000..0adc013 --- /dev/null +++ b/server/packages/kca-app/src/kca/auth.py @@ -0,0 +1,192 @@ +"""Authentication helpers on top of the kaya-oidc mixin.""" +from __future__ import annotations + +from logging import getLogger +from typing import Any, Awaitable, Callable, Dict, Mapping, Optional + +from kaya.core import HttpContext +from kaya.oidc import OIDCProviders, OIDCUser +from kaya.session import Session + +from .models import User +from .tokens import bearer_token, resolve_token + +log = getLogger(__name__) + +PROFILE_CLAIMS = ( + "name", + "family_name", + "given_name", + "middle_name", + "nickname", + "preferred_username", + "profile", + "picture", + "website", + "gender", + "birthdate", + "zoneinfo", + "locale", + "updated_at", +) + +_URL_CLAIMS = frozenset({"profile", "picture", "website"}) +_CLAIM_MAX_LENGTH: Dict[str, int] = { + "name": 255, + "family_name": 255, + "given_name": 255, + "middle_name": 255, + "nickname": 255, + "preferred_username": 255, + "profile": 2048, + "picture": 2048, + "website": 2048, + "gender": 100, + "birthdate": 32, + "zoneinfo": 100, + "locale": 100, +} + +_OIDC_PREFIX = "oidc:" +_USER_SUFFIX = ":user" +_CACHED_USER_ATTR = "_kca_user" + +_UNAUTHENTICATED_BODY = b'{"error":"unauthenticated"}' +_UNAUTHENTICATED_HEADERS = {"content-type": ("application/json",)} + + +def session_user(session: Session) -> Optional[OIDCUser]: + for key in session: + if key.startswith(_OIDC_PREFIX) and key.endswith(_USER_SUFFIX): + claims = session.get(key) + if isinstance(claims, Mapping): + return OIDCUser(claims) + return None + + +def display_name(user: OIDCUser) -> str: + for key in ("preferred_username", "name"): + value = user.get(key) + if isinstance(value, str) and value: + return value + return user.sub + + +def _text_claim(value: Any, max_length: int) -> Optional[str]: + if not isinstance(value, str): + return None + value = value.strip() + if not value: + return None + return value[:max_length] + + +def _url_claim(value: Any, max_length: int) -> Optional[str]: + value = _text_claim(value, max_length) + if value is not None and not value.startswith(("http://", "https://")): + return None + return value + + +def profile_claims(user: OIDCUser) -> Dict[str, Any]: + values: Dict[str, Any] = {} + for name in PROFILE_CLAIMS: + raw = user.get(name) + if name == "updated_at": + values[name] = raw if isinstance(raw, int) and not isinstance(raw, bool) else None + elif name in _URL_CLAIMS: + values[name] = _url_claim(raw, _CLAIM_MAX_LENGTH[name]) + else: + values[name] = _text_claim(raw, _CLAIM_MAX_LENGTH[name]) + return values + + +def current_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[OIDCUser]: + return next(iter(oidc.get_users(ctx).values()), None) + + +async def ensure_user(user: OIDCUser) -> User: + updates: Dict[str, Any] = profile_claims(user) + updates["display_name"] = display_name(user) + row = await User.get_or_none(sub=user.sub) + if row is None: + row = await User.create(sub=user.sub, **updates) + log.info("registered user sub=%s name=%r", user.sub, updates["display_name"]) + return row + changed = [field for field, value in updates.items() if getattr(row, field) != value] + if changed: + for field in changed: + setattr(row, field, updates[field]) + await row.save(update_fields=changed) + return row + + +async def _resolve_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[User]: + secret = bearer_token(ctx) + if secret is not None: + return await resolve_token(secret) + oidc_user = current_user(oidc, ctx) + if oidc_user is None: + return None + return await ensure_user(oidc_user) + + +async def _send_unauthenticated(ctx: HttpContext) -> None: + await ctx.send_bytes(401, _UNAUTHENTICATED_BODY, _UNAUTHENTICATED_HEADERS) + + +async def authenticated_user(oidc: OIDCProviders, ctx: HttpContext) -> Optional[User]: + cached = getattr(ctx, _CACHED_USER_ATTR, None) + if cached is not None: + return cached + return await _resolve_user(oidc, ctx) + + +def cached_user(ctx: HttpContext) -> Optional[User]: + user = getattr(ctx, _CACHED_USER_ATTR, None) + return user if isinstance(user, User) else None + + +def _require_user( + resolve: Callable[[HttpContext], Awaitable[Optional[User]]], +) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + def decorator(handler: Callable[..., Any]) -> Callable[..., Any]: + async def guarded(ctx: HttpContext, *args: Any, **kwargs: Any) -> None: + user = await resolve(ctx) + if user is None: + await _send_unauthenticated(ctx) + return + setattr(ctx, _CACHED_USER_ATTR, user) + await handler(ctx, *args, **kwargs) + + return guarded + + return decorator + + +def require_auth(oidc: OIDCProviders) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + return _require_user(lambda ctx: _resolve_user(oidc, ctx)) + + +def require_session_auth(oidc: OIDCProviders) -> Callable[[Callable[..., Any]], Callable[..., Any]]: + async def resolve_session(ctx: HttpContext) -> Optional[User]: + oidc_user = current_user(oidc, ctx) + if oidc_user is None: + return None + return await ensure_user(oidc_user) + + return _require_user(resolve_session) + + +__all__ = [ + "PROFILE_CLAIMS", + "authenticated_user", + "cached_user", + "current_user", + "display_name", + "ensure_user", + "profile_claims", + "require_auth", + "require_session_auth", + "session_user", +] diff --git a/server/packages/kca-app/src/kca/config.py b/server/packages/kca-app/src/kca/config.py new file mode 100644 index 0000000..de77f03 --- /dev/null +++ b/server/packages/kca-app/src/kca/config.py @@ -0,0 +1,237 @@ +"""Environment-driven configuration for KCA. + +Mirrors kaya's own pattern: read ``os.environ`` directly into a frozen +dataclass. No pydantic-settings, no settings module. +""" +from __future__ import annotations + +import hashlib +import os +import re +from dataclasses import dataclass +from typing import Optional, Tuple +from urllib.parse import quote + +from kca.core.crypto import decode_master_key + +# Per-provider OIDC settings are indexed with a 0-based suffix. +_OIDC_VARIABLES = ( + "PROVIDER", + "ISSUER", + "CLIENT_ID", + "CLIENT_SECRET", + "REDIRECT_URI", + "POST_LOGIN_REDIRECT", + "POST_LOGOUT_REDIRECT", +) +_OIDC_ENV = re.compile(r"^OIDC_(\d+)_([A-Z0-9_]+)$") +_PROVIDER_NAME = re.compile(r"^[A-Za-z0-9_-]+$") + +_DEFAULT_OIDC_ISSUER = "http://localhost:8180/kca" +_DEFAULT_OIDC_CLIENT_ID = "kca" + +#: Development-only master key. Production deployments must set +#: KCA_MASTER_KEY to a random 32-byte base64 value. +_DEV_MASTER_KEY = hashlib.sha256(b"kca-development-master-key").digest() + + +def _env(name: str, default: Optional[str] = None) -> str: + value = os.environ.get(name) + if value is None or value == "": + if default is None: + raise RuntimeError(f"Missing required environment variable: {name}") + return default + return value + + +def _env_list(name: str) -> Optional[Tuple[str, ...]]: + value = os.environ.get(name) + if value is None or value.strip() == "": + return None + return tuple(part.strip() for part in value.split(",") if part.strip()) + + +def _env_bool(name: str, default: bool = False) -> bool: + value = os.environ.get(name) + if value is None or value == "": + return default + return value.strip().lower() in ("1", "true", "yes", "on") + + +@dataclass(frozen=True) +class OidcProviderSettings: + provider: str + issuer: str + client_id: str + client_secret: Optional[str] + redirect_uri: str + post_login_redirect: str + post_logout_redirect: str + + +def _oidc_providers_from_env() -> Tuple[OidcProviderSettings, ...]: + indices: set[int] = set() + for name in os.environ: + match = _OIDC_ENV.match(name) + if match is None: + continue + index, variable = match.groups() + if variable not in _OIDC_VARIABLES: + raise RuntimeError( + f"unknown OIDC environment variable {name!r}; expected one of " + + ", ".join(f"OIDC__{v}" for v in _OIDC_VARIABLES) + ) + indices.add(int(index)) + + if not indices: + return ( + OidcProviderSettings( + provider="default", + issuer=_DEFAULT_OIDC_ISSUER, + client_id=_DEFAULT_OIDC_CLIENT_ID, + client_secret=None, + redirect_uri="http://localhost:8080/auth/default/callback", + post_login_redirect="/", + post_logout_redirect="/", + ), + ) + + missing = sorted(set(range(max(indices) + 1)) - indices) + if missing: + raise RuntimeError( + "OIDC provider indices must be contiguous from 0; missing " + + ", ".join(f"OIDC_{index}_*" for index in missing) + ) + + providers = [] + seen = set() + for index in sorted(indices): + prefix = f"OIDC_{index}_" + provider = _env(prefix + "PROVIDER") + if _PROVIDER_NAME.match(provider) is None: + raise RuntimeError( + f"{prefix}PROVIDER must be a non-empty string of letters, " + f"digits, underscores or hyphens (got {provider!r})" + ) + if provider in seen: + raise RuntimeError(f"duplicate OIDC provider name {provider!r}") + seen.add(provider) + providers.append( + OidcProviderSettings( + provider=provider, + issuer=_env(prefix + "ISSUER"), + client_id=_env(prefix + "CLIENT_ID"), + client_secret=os.environ.get(prefix + "CLIENT_SECRET") or None, + redirect_uri=_env( + prefix + "REDIRECT_URI", + f"http://localhost:8080/auth/{provider}/callback", + ), + post_login_redirect=_env(prefix + "POST_LOGIN_REDIRECT", "/"), + post_logout_redirect=_env(prefix + "POST_LOGOUT_REDIRECT", "/"), + ) + ) + return tuple(providers) + + +def _database_url_from_parts( + engine: str, + user: str, + password: Optional[str], + host: str, + port: str, + name: str, + options: str, +) -> str: + netloc = quote(user, safe="") + if password: + netloc += ":" + quote(password, safe="") + netloc += "@" + host + if port: + netloc += ":" + port + url = f"{engine}://{netloc}/{name}" + options = options.lstrip("?") + if options: + url += "?" + options + return url + + +@dataclass(frozen=True) +class Settings: + database_url: str + oidc_providers: Tuple[OidcProviderSettings, ...] + app_host: str + app_port: int + redis_url: Optional[str] + static_dir: str + logging_config: Optional[str] + #: AES-256-GCM master key protecting private keys at rest. + master_key: bytes + #: True when the master key came from the built-in development default. + master_key_is_dev: bool + max_upload_bytes: int + keygen_concurrency: int + session_cookie_secure: bool + cors_allow_origins: Optional[Tuple[str, ...]] + cors_allow_origin_regex: Optional[str] + cors_allow_methods: Optional[Tuple[str, ...]] + cors_allow_headers: Optional[Tuple[str, ...]] + cors_allow_credentials: bool + cors_expose_headers: Optional[Tuple[str, ...]] + cors_max_age: int + otel_enabled: bool + otel_service_name: str + otel_exporter_endpoint: Optional[str] + otel_exporter_headers: Optional[Tuple[str, ...]] + otel_excluded_paths: Tuple[str, ...] + otel_metric_export_interval_millis: int + otel_metrics_poll_interval_seconds: int + + @staticmethod + def from_env() -> "Settings": + raw_master = os.environ.get("KCA_MASTER_KEY") + master_key_is_dev = not raw_master + master_key = decode_master_key(raw_master) if raw_master else _DEV_MASTER_KEY + return Settings( + database_url=os.environ.get("DATABASE_URL") + or _database_url_from_parts( + engine=_env("DATABASE_ENGINE", "postgres"), + user=_env("DATABASE_USER", "kca"), + password=_env("DATABASE_PASSWORD", "password"), + host=_env("DATABASE_HOST", "localhost"), + port=os.environ.get("DATABASE_PORT", ""), + name=_env("DATABASE_NAME", "kca"), + options=os.environ.get("DATABASE_OPTIONS", ""), + ), + oidc_providers=_oidc_providers_from_env(), + app_host=_env("APP_HOST", "0.0.0.0"), + app_port=int(_env("APP_PORT", "8080")), + redis_url=os.environ.get("REDIS_URL") or None, + static_dir=_env("STATIC_DIR", "web/dist"), + logging_config=os.environ.get("LOGGING_CONFIG") or None, + master_key=master_key, + master_key_is_dev=master_key_is_dev, + max_upload_bytes=int(_env("MAX_UPLOAD_BYTES", str(20 * 1024 * 1024))), + keygen_concurrency=max(1, int(_env("KEYGEN_CONCURRENCY", "2"))), + session_cookie_secure=_env_bool("SESSION_COOKIE_SECURE"), + cors_allow_origins=_env_list("CORS_ALLOW_ORIGINS"), + cors_allow_origin_regex=os.environ.get("CORS_ALLOW_ORIGIN_REGEX") or None, + cors_allow_methods=_env_list("CORS_ALLOW_METHODS"), + cors_allow_headers=_env_list("CORS_ALLOW_HEADERS"), + cors_allow_credentials=_env_bool("CORS_ALLOW_CREDENTIALS"), + cors_expose_headers=_env_list("CORS_EXPOSE_HEADERS"), + cors_max_age=int(_env("CORS_MAX_AGE", "600")), + otel_enabled=_env_bool("OTEL_ENABLED"), + otel_service_name=_env("OTEL_SERVICE_NAME", "kca"), + otel_exporter_endpoint=os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or None, + otel_exporter_headers=_env_list("OTEL_EXPORTER_OTLP_HEADERS"), + otel_excluded_paths=_env_list("OTEL_EXCLUDED_PATHS") or ("/api/health",), + otel_metric_export_interval_millis=int( + _env("OTEL_METRIC_EXPORT_INTERVAL_MILLIS", "60000") + ), + otel_metrics_poll_interval_seconds=int( + _env("OTEL_METRICS_POLL_INTERVAL_SECONDS", "15") + ), + ) + + +settings: Settings = Settings.from_env() diff --git a/server/packages/kca-app/src/kca/http.py b/server/packages/kca-app/src/kca/http.py new file mode 100644 index 0000000..bcd6ece --- /dev/null +++ b/server/packages/kca-app/src/kca/http.py @@ -0,0 +1,73 @@ +"""JSON helpers for kaya HTTP handlers. + +Kaya has no built-in request/response JSON helpers: the request body is an +async byte stream on ``ctx.request_body`` and responses are sent with +``ctx.send_*``. These wrappers handle the boilerplate of draining the body, +parsing JSON, and sending JSON responses. +""" +from __future__ import annotations + +import json +from typing import Any, List, Mapping + +from kaya.core import HttpContext + +JSON_HEADERS = {"content-type": ("application/json",)} + + +class JsonRequestError(ValueError): + """Raised by :func:`read_json` when the request body is not valid JSON + or is not a JSON object.""" + + +def extract_query_params(query_string: str) -> Mapping[str, List[str]]: + """Parse a raw query string into a mapping of param name to list of + values. + + Wraps :func:`urllib.parse.parse_qs` so callers don't repeat the + incantation; always returns a mapping (never None). + """ + from urllib.parse import parse_qs + + return parse_qs(query_string, keep_blank_values=True) + + +async def read_json(ctx: HttpContext) -> dict: + """Drain and parse the request body as JSON. + + Returns the parsed ``dict`` on success. Raises :class:`JsonRequestError` + with a short human-readable reason on failure. + """ + body = b"" + async for chunk in ctx.request_body: + body += chunk + if not body: + raise JsonRequestError("empty body") + try: + parsed = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise JsonRequestError("invalid JSON") from exc + if not isinstance(parsed, dict): + raise JsonRequestError("JSON body must be an object") + return parsed + + +async def read_json_optional(ctx: HttpContext) -> dict: + """Like :func:`read_json` but treats an empty body as ``{}``.""" + try: + return await read_json(ctx) + except JsonRequestError as exc: + if str(exc) == "empty body": + return {} + raise + + +async def send_json(ctx: HttpContext, status: int, payload: Any) -> None: + """Send ``payload`` as a JSON response.""" + body = json.dumps(payload).encode("utf-8") + await ctx.send_bytes(status, body, JSON_HEADERS) + + +async def send_error(ctx: HttpContext, status: int, message: str) -> None: + """Send a JSON error envelope.""" + await send_json(ctx, status, {"error": message}) diff --git a/server/packages/kca-app/src/kca/logging_config.py b/server/packages/kca-app/src/kca/logging_config.py new file mode 100644 index 0000000..017c045 --- /dev/null +++ b/server/packages/kca-app/src/kca/logging_config.py @@ -0,0 +1,84 @@ +"""Logging setup for the kca application. + +Configured once at app import time (see :mod:`kca.app`). By default a +single console handler at DEBUG level is installed, formatting records as +``{asctime} [{levelname}] ({processName}/{threadName}) - {name} - {message}``; +``urllib3.connectionpool`` is capped at INFO so its per-request DEBUG +chatter does not drown the log. + +Point the ``LOGGING_CONFIG`` environment variable at a YAML file to take +over the configuration entirely; the file follows the +:data:`logging.config.dictConfig` schema, e.g.:: + + version: 1 + disable_existing_loggers: false + formatters: + default: + format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}" + style: "{" + handlers: + console: + class: logging.StreamHandler + formatter: default + root: + level: WARNING + handlers: [console] + loggers: + kca: + level: INFO + +``disable_existing_loggers`` should stay ``false``: Granian configures its +own loggers before importing the application, and disabling them would +silence the server and Tortoise. +""" +from __future__ import annotations + +from logging.config import dictConfig +from typing import Optional + +import yaml + +DEFAULT_CONFIG = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "format": "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}", + "style": "{", + }, + }, + "handlers": { + "console": { + "class": "logging.StreamHandler", + "formatter": "default", + "level": "DEBUG", + }, + }, + "root": { + "level": "DEBUG", + "handlers": ["console"], + }, + "loggers": { + # Every pooled HTTP request is a DEBUG record; keep the logger's + # INFO+ output, drop the per-connection noise. + "urllib3.connectionpool": { + "level": "INFO", + }, + }, +} + + +def configure_logging(config_path: Optional[str]) -> None: + """Apply the YAML logging configuration at ``config_path``, or the + built-in default when unset.""" + if config_path is None: + dictConfig(DEFAULT_CONFIG) + return + try: + with open(config_path, "rb") as handle: + config = yaml.safe_load(handle) + except OSError as exc: + raise RuntimeError(f"Cannot read LOGGING_CONFIG file: {config_path}") from exc + if not isinstance(config, dict): + raise RuntimeError(f"LOGGING_CONFIG file is not a YAML mapping: {config_path}") + dictConfig(config) diff --git a/server/packages/kca-app/src/kca/mixin.py b/server/packages/kca-app/src/kca/mixin.py new file mode 100644 index 0000000..5d866ab --- /dev/null +++ b/server/packages/kca-app/src/kca/mixin.py @@ -0,0 +1,49 @@ +"""The KCA mixin: mounts the whole API on a kaya app.""" +from __future__ import annotations + +from dataclasses import dataclass + +from kaya.core import KayaApp, KayaMixin +from kaya.oidc import OIDCProviders + + +@dataclass +class Kca: + oidc: OIDCProviders + + +class KcaMixin(KayaMixin): + def __init__(self, kca: Kca) -> None: + self.kca = kca + + def apply(self, app: KayaApp) -> None: + from .routes import ( + auth, + certificates, + crls, + health, + import_, + keys, + me, + requests, + search, + templates, + tokens, + vaults, + ) + + for module in ( + health, + auth, + me, + tokens, + vaults, + search, + keys, + requests, + certificates, + crls, + templates, + import_, + ): + module.register(app, self.kca) diff --git a/server/packages/kca-app/src/kca/models.py b/server/packages/kca-app/src/kca/models.py new file mode 100644 index 0000000..5aca958 --- /dev/null +++ b/server/packages/kca-app/src/kca/models.py @@ -0,0 +1,332 @@ +"""Tortoise ORM models for KCA. + +The schema mirrors XCA's entities rather than spendwise's expense-tracking +model: a *vault* is one XCA database, and keys, certificates, requests, CRLs, +templates and revocations are its items. Private key material is stored as +AES-256-GCM ciphertext (``private_der`` + ``private_nonce``); the plaintext +exists only in memory during a request. +""" +from __future__ import annotations + +from typing import List + +from tortoise import fields +from tortoise.models import Model + + +class User(Model): + """One OIDC identity that has logged in at least once.""" + + id = fields.UUIDField(pk=True) + sub = fields.CharField(max_length=255, unique=True) + display_name = fields.CharField(max_length=200) + + name = fields.CharField(max_length=255, null=True) + family_name = fields.CharField(max_length=255, null=True) + given_name = fields.CharField(max_length=255, null=True) + middle_name = fields.CharField(max_length=255, null=True) + nickname = fields.CharField(max_length=255, null=True) + preferred_username = fields.CharField(max_length=255, null=True) + profile = fields.CharField(max_length=2048, null=True) + picture = fields.CharField(max_length=2048, null=True) + website = fields.CharField(max_length=2048, null=True) + gender = fields.CharField(max_length=100, null=True) + birthdate = fields.CharField(max_length=32, null=True) + zoneinfo = fields.CharField(max_length=100, null=True) + locale = fields.CharField(max_length=100, null=True) + updated_at = fields.BigIntField(null=True) + + created_at = fields.DatetimeField(auto_now_add=True) + last_seen_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "app_user" + ordering = ["display_name", "id"] + + def __str__(self) -> str: + return f"User(sub={self.sub!r}, display_name={self.display_name!r})" + + +class ApiToken(Model): + """Personal access token for programmatic API access.""" + + id = fields.UUIDField(pk=True) + user_sub = fields.CharField(max_length=255, db_index=True) + name = fields.CharField(max_length=100) + token_prefix = fields.CharField(max_length=16) + token_hash = fields.CharField(max_length=64, unique=True) + created_at = fields.DatetimeField(auto_now_add=True) + last_used_at = fields.DatetimeField(null=True) + expires_at = fields.DateField(null=True) + + class Meta: + table = "api_token" + ordering = ["-created_at", "id"] + + def __str__(self) -> str: + return f"ApiToken(name={self.name!r}, prefix={self.token_prefix!r})" + + +class Vault(Model): + """One private XCA database owned by an OIDC user.""" + + id = fields.UUIDField(pk=True) + owner_sub = fields.CharField(max_length=255, db_index=True) + name = fields.CharField(max_length=200) + description = fields.TextField(default="") + settings = fields.JSONField(default=dict) + version = fields.IntField(default=1) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + keys: fields.ReverseRelation["Key"] + certificates: fields.ReverseRelation["Certificate"] + requests: fields.ReverseRelation["Request"] + crls: fields.ReverseRelation["Crl"] + templates: fields.ReverseRelation["Template"] + + class Meta: + table = "vault" + ordering = ["name", "id"] + + def __str__(self) -> str: + return f"Vault(name={self.name!r})" + + +class Key(Model): + """A public or private key item.""" + + id = fields.UUIDField(pk=True) + vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField( + "models.Vault", related_name="keys", on_delete=fields.CASCADE + ) + name = fields.CharField(max_length=200) + comment = fields.TextField(default="") + source = fields.CharField(max_length=20, default="generated") + key_type = fields.CharField(max_length=20) + key_size = fields.IntField(null=True) + curve = fields.CharField(max_length=100, null=True) + public_der = fields.BinaryField() + public_key_sha256 = fields.CharField(max_length=64, db_index=True) + private_der = fields.BinaryField(null=True) + private_nonce = fields.BinaryField(null=True) + has_private = fields.BooleanField(default=False) + version = fields.IntField(default=1) + search_text = fields.TextField(default="") + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "key" + ordering = ["name", "id"] + unique_together = (("vault", "public_key_sha256"),) + indexes = (("vault", "name"), ("vault", "key_type")) + + def __str__(self) -> str: + return f"Key(name={self.name!r}, type={self.key_type!r})" + + +class Certificate(Model): + """An X.509 certificate item.""" + + id = fields.UUIDField(pk=True) + vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField( + "models.Vault", related_name="certificates", on_delete=fields.CASCADE + ) + key: fields.ForeignKeyRelation[Key] = fields.ForeignKeyField( + "models.Key", related_name="certificates", on_delete=fields.SET_NULL, null=True + ) + issuer: fields.ForeignKeyRelation["Certificate"] = fields.ForeignKeyField( + "models.Certificate", related_name="children", on_delete=fields.SET_NULL, null=True + ) + request: fields.ForeignKeyRelation["Request"] = fields.ForeignKeyField( + "models.Request", related_name="certificates", on_delete=fields.SET_NULL, null=True + ) + name = fields.CharField(max_length=200) + comment = fields.TextField(default="") + source = fields.CharField(max_length=20, default="generated") + serial_hex = fields.CharField(max_length=128, db_index=True) + subject_der = fields.BinaryField() + issuer_der = fields.BinaryField() + subject_hash = fields.CharField(max_length=64, db_index=True) + issuer_hash = fields.CharField(max_length=64, db_index=True) + not_before = fields.DatetimeField() + not_after = fields.DatetimeField() + is_ca = fields.BooleanField(default=False, db_index=True) + path_length = fields.IntField(null=True) + cert_der = fields.BinaryField() + public_key_der = fields.BinaryField() + signature_algorithm = fields.CharField(max_length=100, default="") + fingerprint_md5 = fields.CharField(max_length=32, default="") + fingerprint_sha1 = fields.CharField(max_length=40, default="") + fingerprint_sha256 = fields.CharField(max_length=64, db_index=True) + version = fields.IntField(default=1) + search_text = fields.TextField(default="") + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "certificate" + ordering = ["name", "id"] + unique_together = (("vault", "fingerprint_sha256"),) + indexes = (("vault", "name"), ("vault", "issuer"), ("vault", "not_after")) + + def __str__(self) -> str: + return f"Certificate(name={self.name!r}, serial={self.serial_hex!r})" + + +class Authority(Model): + """CA-specific properties, one row per CA certificate.""" + + id = fields.UUIDField(pk=True) + certificate: fields.OneToOneRelation[Certificate] = fields.OneToOneField( + "models.Certificate", related_name="authority", on_delete=fields.CASCADE + ) + template: fields.ForeignKeyRelation["Template"] = fields.ForeignKeyField( + "models.Template", related_name="authorities", on_delete=fields.SET_NULL, null=True + ) + crl_expire = fields.DatetimeField(null=True) + crl_number = fields.IntField(default=0) + crl_days = fields.IntField(default=30) + tls_auth_key = fields.BinaryField(null=True) + tls_auth_key_nonce = fields.BinaryField(null=True) + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "authority" + + def __str__(self) -> str: + return f"Authority(certificate={self.certificate_id})" + + +class CertificateRevocation(Model): + """A serial revoked by a CA (XCA stores revocations on the CA).""" + + id = fields.UUIDField(pk=True) + ca: fields.ForeignKeyRelation[Certificate] = fields.ForeignKeyField( + "models.Certificate", related_name="revocations", on_delete=fields.CASCADE + ) + serial_hex = fields.CharField(max_length=128, db_index=True) + revoked_at = fields.DatetimeField() + invalidity_date = fields.DatetimeField(null=True) + reason = fields.CharField(max_length=50, default="unspecified") + crl_number = fields.IntField(default=0) + created_at = fields.DatetimeField(auto_now_add=True) + + class Meta: + table = "certificate_revocation" + ordering = ["-revoked_at", "id"] + unique_together = (("ca", "serial_hex"),) + + def __str__(self) -> str: + return f"Revocation(ca={self.ca_id}, serial={self.serial_hex!r})" + + +class Request(Model): + """A PKCS#10 certificate signing request item.""" + + id = fields.UUIDField(pk=True) + vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField( + "models.Vault", related_name="requests", on_delete=fields.CASCADE + ) + key: fields.ForeignKeyRelation[Key] = fields.ForeignKeyField( + "models.Key", related_name="requests", on_delete=fields.SET_NULL, null=True + ) + name = fields.CharField(max_length=200) + comment = fields.TextField(default="") + source = fields.CharField(max_length=20, default="generated") + subject_der = fields.BinaryField() + req_der = fields.BinaryField() + public_key_der = fields.BinaryField() + request_sha256 = fields.CharField(max_length=64, db_index=True) + signed = fields.BooleanField(default=False) + signed_at = fields.DatetimeField(null=True) + attributes = fields.JSONField(default=list) + version = fields.IntField(default=1) + search_text = fields.TextField(default="") + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "request" + ordering = ["name", "id"] + unique_together = (("vault", "request_sha256"),) + indexes = (("vault", "name"), ("vault", "signed")) + + def __str__(self) -> str: + return f"Request(name={self.name!r}, signed={self.signed})" + + +class Crl(Model): + """A certificate revocation list item.""" + + id = fields.UUIDField(pk=True) + vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField( + "models.Vault", related_name="crls", on_delete=fields.CASCADE + ) + issuer: fields.ForeignKeyRelation[Certificate] = fields.ForeignKeyField( + "models.Certificate", related_name="crls", on_delete=fields.SET_NULL, null=True + ) + name = fields.CharField(max_length=200) + comment = fields.TextField(default="") + source = fields.CharField(max_length=20, default="generated") + crl_der = fields.BinaryField() + crl_hash = fields.CharField(max_length=64, db_index=True) + crl_number = fields.IntField(null=True) + last_update = fields.DatetimeField() + next_update = fields.DatetimeField(null=True) + revoked_count = fields.IntField(default=0) + signature_algorithm = fields.CharField(max_length=100, default="") + search_text = fields.TextField(default="") + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "crl" + ordering = ["-last_update", "id"] + unique_together = (("vault", "crl_hash"),) + indexes = (("vault", "name"), ("vault", "issuer")) + + def __str__(self) -> str: + return f"Crl(name={self.name!r}, number={self.crl_number})" + + +class Template(Model): + """An XCA-style template: subject + extension/validity defaults.""" + + id = fields.UUIDField(pk=True) + vault: fields.ForeignKeyRelation[Vault] = fields.ForeignKeyField( + "models.Vault", related_name="templates", on_delete=fields.CASCADE + ) + name = fields.CharField(max_length=200) + comment = fields.TextField(default="") + source = fields.CharField(max_length=20, default="generated") + subject = fields.JSONField(default=list) + settings = fields.JSONField(default=dict) + version = fields.IntField(default=1) + search_text = fields.TextField(default="") + created_at = fields.DatetimeField(auto_now_add=True) + updated_at = fields.DatetimeField(auto_now=True) + + class Meta: + table = "template" + ordering = ["name", "id"] + indexes = (("vault", "name"),) + + def __str__(self) -> str: + return f"Template(name={self.name!r})" + + +__all__: List[str] = [ + "ApiToken", + "Authority", + "Certificate", + "CertificateRevocation", + "Crl", + "Key", + "Request", + "Template", + "User", + "Vault", +] diff --git a/server/packages/kca-app/src/kca/openapi.py b/server/packages/kca-app/src/kca/openapi.py new file mode 100644 index 0000000..2aeb0e5 --- /dev/null +++ b/server/packages/kca-app/src/kca/openapi.py @@ -0,0 +1,192 @@ +"""Shared OpenAPI fragments and the authentication post-processor.""" +from __future__ import annotations + +from typing import Any, Dict, Iterable, List, Mapping + +from kaya.core import KayaApp +from kaya.openapi import OpenAPIMixin, schema_ref + +from .auth import PROFILE_CLAIMS + +__all__ = [ + "API_SECURITY", + "PUBLIC_PATHS", + "SCHEMAS", + "SECURITY_SCHEMES", + "SESSION_ONLY_PATHS", + "KcaOpenAPI", + "error_response", + "json_response", + "schema_ref", +] + +SESSION_COOKIE_NAME = "session_id" + +SECURITY_SCHEMES: Dict[str, Any] = { + "sessionCookie": { + "type": "apiKey", + "in": "cookie", + "name": SESSION_COOKIE_NAME, + "description": "The OIDC login session (interactive use).", + }, + "bearerToken": { + "type": "http", + "scheme": "bearer", + "description": "A personal access token, ``Authorization: Bearer kca_...``.", + }, +} + +PUBLIC_PATHS = frozenset({"/api/health", "/api/auth/providers"}) +SESSION_ONLY_PATHS = frozenset({"/api/tokens", "/api/tokens/{token_id}"}) +API_SECURITY: List[Dict[str, List[str]]] = [ + {"sessionCookie": []}, + {"bearerToken": []}, +] +SESSION_SECURITY: List[Dict[str, List[str]]] = [{"sessionCookie": []}] + + +def _object(properties: Mapping[str, Any], required: Iterable[str] = ()) -> Dict[str, Any]: + schema: Dict[str, Any] = {"type": "object", "properties": dict(properties)} + if required: + schema["required"] = list(required) + return schema + + +def _string(*, nullable: bool = False) -> Dict[str, Any]: + return {"type": ["string", "null"] if nullable else "string"} + + +def _integer(*, nullable: bool = False) -> Dict[str, Any]: + return {"type": ["integer", "null"] if nullable else "integer"} + + +def _boolean() -> Dict[str, Any]: + return {"type": "boolean"} + + +def _date_time() -> Dict[str, Any]: + return {"type": "string", "format": "date-time"} + + +def _array(items: Mapping[str, Any]) -> Dict[str, Any]: + return {"type": "array", "items": items} + + +def _loose(name: str) -> Dict[str, Any]: + """A named, intentionally loose object schema. + + The API is documented at the field level in the README and the route + descriptions; keeping the OpenAPI component loose avoids duplicating the + large nested extension/template structures here. + """ + return {"type": "object", "description": name, "additionalProperties": True} + + +def json_response(description: str, schema: Mapping[str, Any]) -> Dict[str, Any]: + return {"description": description, "content": {"application/json": {"schema": dict(schema)}}} + + +def error_response(description: str) -> Dict[str, Any]: + return json_response(description, schema_ref("Error")) + + +ERROR_SCHEMA = _object({"error": _string()}, required=("error",)) +HEALTH_SCHEMA = _object({"status": _string()}, required=("status",)) +ME_SCHEMA = _object( + { + "sub": _string(), + "display_name": _string(), + **{ + claim: _integer(nullable=True) if claim == "updated_at" else _string(nullable=True) + for claim in PROFILE_CLAIMS + }, + "master_key_is_dev": _boolean(), + }, + required=("sub", "display_name", *PROFILE_CLAIMS, "master_key_is_dev"), +) +AUTH_PROVIDER_SCHEMA = _object({"name": _string(), "login_url": _string()}, required=("name", "login_url")) +AUTH_PROVIDERS_PAGE_SCHEMA = _object({"providers": _array(schema_ref("AuthProvider"))}, required=("providers",)) +API_TOKEN_SCHEMA = _object( + { + "id": _string(), + "name": _string(), + "prefix": _string(), + "created_at": _date_time(), + "last_used_at": _string(nullable=True), + "expires_at": _string(nullable=True), + } +) +CREATED_API_TOKEN_SCHEMA = _object( + { + "id": _string(), + "name": _string(), + "prefix": _string(), + "created_at": _date_time(), + "last_used_at": _string(nullable=True), + "expires_at": _string(nullable=True), + "token": _string(), + } +) +API_TOKENS_PAGE_SCHEMA = _object({"results": _array(schema_ref("ApiToken"))}, required=("results",)) + +SCHEMAS: Dict[str, Any] = { + "Error": ERROR_SCHEMA, + "Health": HEALTH_SCHEMA, + "Me": ME_SCHEMA, + "AuthProvider": AUTH_PROVIDER_SCHEMA, + "AuthProvidersPage": AUTH_PROVIDERS_PAGE_SCHEMA, + "ApiToken": API_TOKEN_SCHEMA, + "CreatedApiToken": CREATED_API_TOKEN_SCHEMA, + "ApiTokensPage": API_TOKENS_PAGE_SCHEMA, +} +for name in ( + "VaultSummary", + "VaultDetail", + "VaultsPage", + "KeySummary", + "KeyDetail", + "KeysPage", + "CertificateSummary", + "CertificateDetail", + "CertificatesPage", + "RequestSummary", + "RequestDetail", + "RequestsPage", + "CrlSummary", + "CrlDetail", + "CrlsPage", + "TemplateSummary", + "TemplateDetail", + "TemplatesPage", + "Revocation", + "RevocationsPage", + "SearchResult", + "SearchPage", + "VaultSummaryPage", + "ImportPreview", + "ImportResult", + "ExportPayload", +): + SCHEMAS[name] = _loose(name) + + +class KcaOpenAPI(OpenAPIMixin): + """Attach the authentication schemes to the generated document.""" + + def generate_spec(self, app: KayaApp) -> Mapping[str, Any]: + spec: Dict[str, Any] = dict(super().generate_spec(app)) + paths = spec.get("paths") + if isinstance(paths, Mapping): + for path, item in paths.items(): + if not isinstance(item, Mapping): + continue + if path in PUBLIC_PATHS or not path.startswith("/api/"): + security: List[Dict[str, List[str]]] = [] + elif path in SESSION_ONLY_PATHS: + security = SESSION_SECURITY + else: + security = API_SECURITY + for operation in item.values(): + if isinstance(operation, dict): + operation["security"] = security + return spec diff --git a/server/packages/kca-app/src/kca/pagination.py b/server/packages/kca-app/src/kca/pagination.py new file mode 100644 index 0000000..97b412e --- /dev/null +++ b/server/packages/kca-app/src/kca/pagination.py @@ -0,0 +1,158 @@ +"""Cursor-based pagination for listing endpoints. + +Uses keyset pagination (not OFFSET/LIMIT): each page ends with an opaque +cursor encoding the sort key tuple of the last item on that page; the next +request passes that cursor and the query continues from the point it left +off. This is stable under concurrent inserts and cheaper than OFFSET for +large result sets. + +Cursor is a base64-encoded JSON object mapping the sort-field names to the +values of the last item on the previous page. It's opaque to callers and +must be treated as a black box. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple + +from tortoise.queryset import QuerySet + +from .http import extract_query_params + +if TYPE_CHECKING: + from tortoise.models import Model + +DEFAULT_LIMIT = 20 +MAX_LIMIT = 100 +MIN_LIMIT = 1 + +CURSOR_PARAM = "cursor" +LIMIT_PARAM = "limit" + + +class CursorDecodeError(ValueError): + """Raised when the ``cursor`` query param cannot be decoded.""" + + +def encode_cursor(values: Dict[str, Any]) -> str: + import base64 + import json + + # ``default=str`` handles datetimes/dates (ISO) so keyset cursors can + # carry date-typed sort fields. + raw = json.dumps(values, separators=(",", ":"), default=str).encode("utf-8") + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def decode_cursor(s: Optional[str]) -> Optional[Dict[str, Any]]: + import base64 + import json + + if s is None or s == "": + return None + try: + # Tolerate missing padding. + padded = s + "=" * (-len(s) % 4) + raw = base64.urlsafe_b64decode(padded.encode("ascii")) + obj = json.loads(raw.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as exc: + raise CursorDecodeError("invalid cursor") from exc + if not isinstance(obj, dict): + raise CursorDecodeError("invalid cursor") + return obj + + +@dataclass(frozen=True) +class Cursor: + limit: int + after: Optional[Dict[str, Any]] + + +def parse_cursor_params(query_string: str) -> Cursor: + params = extract_query_params(query_string) + limit_raw = params.get(LIMIT_PARAM) + if limit_raw: + try: + limit = int(limit_raw[0]) + except ValueError as exc: + raise CursorDecodeError("invalid limit") from exc + else: + limit = DEFAULT_LIMIT + limit = max(MIN_LIMIT, min(MAX_LIMIT, limit)) + after = decode_cursor(params.get(CURSOR_PARAM, [None])[0]) + return Cursor(limit=limit, after=after) + + +# A sort field: (model field name, "ASC" or "DESC"). The tuple is the full +# keyset; the cursor encodes exactly these fields. +Sort = List[Tuple[str, str]] + + +def _coerce_sort_values( + model: "type[Model]", sort: Sort, after: Dict[str, Any] +) -> Dict[str, Any]: + """Restore the Python type of every decoded cursor value. + + The cursor is JSON, so field values travel as strings (datetimes via + ``default=str``). asyncpg rejects strings where a ``timestamp`` + parameter is expected, and sqlite only tolerates them by accident, so + each value is run through its model field's ``to_python_value`` before + it reaches the keyset filter. + """ + fields = model._meta.fields_map + coerced: Dict[str, Any] = {} + for field, _ in sort: + value = after.get(field) + field_obj = fields.get(field) + if value is not None and field_obj is not None: + value = field_obj.to_python_value(value) + coerced[field] = value + return coerced + + +def _keyset_where(sort: Sort, after: Dict[str, Any]): + """Build a Tortoise ``Q`` filter from a cursor.""" + from tortoise.queryset import Q # local to keep import edge narrow + + clauses = [] + for i, (field, direction) in enumerate(sort): + key = f"{field}__{'gt' if direction == 'ASC' else 'lt'}" + value = after.get(field) + if value is None: + return Q() + clause = Q(**{key: value}) + for j in range(i): + prev_field, _ = sort[j] + prev_value = after.get(prev_field) + if prev_value is None: + return Q() + clause = clause & Q(**{prev_field: prev_value}) + clauses.append(clause) + result = clauses[0] + for clause in clauses[1:]: + result = result | clause + return result + + +async def paginate( + queryset: QuerySet, + sort: Sort, + cursor: Cursor, +) -> Tuple[List[Any], Optional[str]]: + """Return one page of ``queryset`` plus the opaque cursor to continue.""" + order_by: List[str] = [] + for field, direction in sort: + order_by.append(field if direction == "ASC" else f"-{field}") + qs = queryset.order_by(*order_by) + if cursor.after: + after = _coerce_sort_values(queryset.model, sort, cursor.after) + qs = qs.filter(_keyset_where(sort, after)) + rows = await qs.limit(cursor.limit + 1) + if len(rows) <= cursor.limit: + return rows, None + page = rows[: cursor.limit] + last = page[-1] + key: Dict[str, Any] = {} + for field, _ in sort: + key[field] = getattr(last, field) + return page, encode_cursor(key) diff --git a/server/packages/kca-app/src/kca/py.typed b/server/packages/kca-app/src/kca/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/server/packages/kca-app/src/kca/routes/__init__.py b/server/packages/kca-app/src/kca/routes/__init__.py new file mode 100644 index 0000000..cd2194d --- /dev/null +++ b/server/packages/kca-app/src/kca/routes/__init__.py @@ -0,0 +1 @@ +"""HTTP route modules.""" diff --git a/server/packages/kca-app/src/kca/routes/auth.py b/server/packages/kca-app/src/kca/routes/auth.py new file mode 100644 index 0000000..f2ccb4e --- /dev/null +++ b/server/packages/kca-app/src/kca/routes/auth.py @@ -0,0 +1,39 @@ +"""OIDC provider list and provider-agnostic login/logout routes.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from kaya.core import HttpContext, KayaApp +from kaya.openapi import operation + +from ..http import send_json +from ..openapi import json_response, schema_ref + +if TYPE_CHECKING: + from ..mixin import Kca + + +def _login_url(kca: "Kca", provider: str) -> str: + return f"{kca.oidc.base_path}/{provider}/login" + + +def register(app: KayaApp, kca: "Kca") -> None: + providers = list(kca.oidc.providers) + + @app.GET("/api/auth/providers") + @operation(summary="Configured login providers", tags=["auth"], + responses={200: json_response("The configured providers", schema_ref("AuthProvidersPage"))}) + async def auth_providers(ctx: HttpContext) -> None: + await send_json(ctx, 200, { + "providers": [{"name": name, "login_url": _login_url(kca, name)} for name in providers] + }) + + @app.GET("/auth/login") + async def login(ctx: HttpContext) -> None: + await ctx.send_empty(302, {"Location": _login_url(kca, providers[0])}) + + @app.GET("/auth/logout") + async def logout(ctx: HttpContext) -> None: + logged_in = kca.oidc.logged_in_providers(ctx) + location = f"{kca.oidc.base_path}/{logged_in[0]}/logout" if logged_in else "/" + await ctx.send_empty(302, {"Location": location}) diff --git a/server/packages/kca-app/src/kca/routes/certificates.py b/server/packages/kca-app/src/kca/routes/certificates.py new file mode 100644 index 0000000..d4109f3 --- /dev/null +++ b/server/packages/kca-app/src/kca/routes/certificates.py @@ -0,0 +1,612 @@ +"""Certificates: issue, renew, revoke, CA properties and revocation manager.""" +from __future__ import annotations + +import base64 +import os +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any, Dict, Optional + +from kaya.core import HttpContext, KayaApp +from kaya.openapi import operation + +from kca.core import certificates as cert_core +from kca.core import crls as crl_core +from kca.core import exports as export_core +from kca.core import requests as req_core +from kca.core import templates as template_core +from kca.core.crypto import EncryptedBlob, decrypt_blob, encrypt_blob + +from ..auth import authenticated_user, require_auth +from ..config import settings +from ..http import JsonRequestError, read_json, send_error, send_json +from ..models import Authority, Certificate, CertificateRevocation, Crl, Key, Request, Template +from ..openapi import error_response, json_response, schema_ref +from ..service import ( + ValidationError, + chain_for_certificate, + delete_certificate_cascade, + generate_crl, + issue_certificate, + load_private_key, + parse_datetime, + parse_uuid, + renew_certificate, + revoke_certificate, + save_key, + search_text, + serialize_certificate_detail, + serialize_certificate_summary, + serialize_revocation, + serialize_template_summary, + serialize_crl_detail, + vault_for_owner, + vault_settings, +) + +if TYPE_CHECKING: + from ..mixin import Kca + + +async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str): + user = await authenticated_user(kca.oidc, ctx) + assert user is not None + vault = await vault_for_owner(user.sub, parse_uuid(vault_id)) + if vault is None: + await send_error(ctx, 404, "vault not found") + return vault + + +async def _cert(vault, cert_id: str) -> Optional[Certificate]: + uuid = parse_uuid(cert_id) + return await Certificate.get_or_none(id=uuid, vault_id=vault.id) if uuid else None + + +def register(app: KayaApp, kca: "Kca") -> None: + @app.GET("/api/vaults/${vault_id}/certificates") + @operation(summary="List certificates", tags=["certificates"]) + @require_auth(kca.oidc) + async def list_certificates(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + rows = await Certificate.filter(vault_id=vault.id).order_by("name", "id") + await send_json(ctx, 200, {"results": [await serialize_certificate_summary(c) for c in rows]}) + + @app.POST("/api/vaults/${vault_id}/certificates") + @operation(summary="Issue a certificate", tags=["certificates"]) + @require_auth(kca.oidc) + async def create_certificate(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + user = await authenticated_user(kca.oidc, ctx) + assert user is not None + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + try: + cert = await issue_certificate(vault, body, user=user) + except ValidationError as exc: + await send_error(ctx, 400, str(exc)) + return + except ValueError as exc: + await send_error(ctx, 400, str(exc)) + return + await send_json(ctx, 201, await serialize_certificate_detail(cert, vault)) + + @app.GET("/api/vaults/${vault_id}/certificates/${cert_id}") + @operation(summary="Certificate detail", tags=["certificates"]) + @require_auth(kca.oidc) + async def get_certificate(ctx: HttpContext, vault_id: str, cert_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + cert = await _cert(vault, cert_id) + if cert is None: + await send_error(ctx, 404, "certificate not found") + return + await send_json(ctx, 200, await serialize_certificate_detail(cert, vault)) + + @app.PATCH("/api/vaults/${vault_id}/certificates/${cert_id}") + @operation(summary="Update certificate metadata", tags=["certificates"]) + @require_auth(kca.oidc) + async def update_certificate(ctx: HttpContext, vault_id: str, cert_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + cert = await _cert(vault, cert_id) + if cert is None: + await send_error(ctx, 404, "certificate not found") + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + if "version" in body and int(body["version"]) != cert.version: + await send_error(ctx, 409, "the certificate changed since it was loaded") + return + if "name" in body: + if not isinstance(body["name"], str) or not body["name"].strip(): + await send_error(ctx, 400, "name must not be empty") + return + cert.name = body["name"].strip()[:200] + if "comment" in body: + cert.comment = str(body["comment"])[:8000] + cert.version += 1 + await cert.save() + await send_json(ctx, 200, await serialize_certificate_detail(cert, vault)) + + @app.DELETE("/api/vaults/${vault_id}/certificates/${cert_id}") + @operation(summary="Delete a certificate", tags=["certificates"]) + @require_auth(kca.oidc) + async def delete_certificate(ctx: HttpContext, vault_id: str, cert_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + cert = await _cert(vault, cert_id) + if cert is None: + await send_error(ctx, 404, "certificate not found") + return + await delete_certificate_cascade(vault, cert) + await ctx.send_empty(204) + + @app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/export") + @operation(summary="Export a certificate", tags=["certificates"]) + @require_auth(kca.oidc) + async def export_certificate(ctx: HttpContext, vault_id: str, cert_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + cert = await _cert(vault, cert_id) + if cert is None: + await send_error(ctx, 404, "certificate not found") + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + parsed = cert_core.parse_certificate(bytes(cert.cert_der)) + chain = [cert_core.parse_certificate(bytes(item.cert_der)) for item in await chain_for_certificate(cert, vault)] + private_key = None + if cert.key_id: + key = await Key.get_or_none(id=cert.key_id) + if key is not None and key.has_private: + private_key = await load_private_key(key) + authority = await Authority.get_or_none(certificate_id=cert.id) + tls_auth = None + if authority is not None and authority.tls_auth_key and authority.tls_auth_key_nonce: + raw = decrypt_blob( + EncryptedBlob(bytes(authority.tls_auth_key), bytes(authority.tls_auth_key_nonce)), + master_key=settings.master_key, + aad=str(cert.id).encode(), + ) + tls_auth = _format_tls_auth(raw) + try: + result = export_core.export_certificate( + parsed, + str(body.get("format") or "pem"), + name=cert.name, + chain=chain, + private_key=private_key, + password=body.get("password"), + tls_auth_key=tls_auth, + ) + except ValueError as exc: + await send_error(ctx, 400, str(exc)) + return + await send_json(ctx, 200, { + "filename": result.filename, + "content_type": result.content_type, + "content_base64": base64.b64encode(result.data).decode("ascii"), + }) + + @app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/renew") + @operation(summary="Renew a certificate", tags=["certificates"]) + @require_auth(kca.oidc) + async def renew(ctx: HttpContext, vault_id: str, cert_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + cert = await _cert(vault, cert_id) + if cert is None: + await send_error(ctx, 404, "certificate not found") + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + try: + new_cert = await renew_certificate(vault, cert, body) + except ValueError as exc: + await send_error(ctx, 400, str(exc)) + return + await send_json(ctx, 201, await serialize_certificate_detail(new_cert, vault)) + + @app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/revoke") + @operation(summary="Revoke a certificate", tags=["certificates"]) + @require_auth(kca.oidc) + async def revoke(ctx: HttpContext, vault_id: str, cert_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + cert = await _cert(vault, cert_id) + if cert is None: + await send_error(ctx, 404, "certificate not found") + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + try: + row = await revoke_certificate(vault, cert, body) + except ValueError as exc: + await send_error(ctx, 400, str(exc)) + return + await send_json(ctx, 200, await serialize_revocation(row)) + + # ------------------------------------------------------------------ + # CA properties / revocation manager / CRL generation + # ------------------------------------------------------------------ + + @app.GET("/api/vaults/${vault_id}/certificates/${cert_id}/ca") + @operation(summary="CA properties", tags=["ca"]) + @require_auth(kca.oidc) + async def get_ca(ctx: HttpContext, vault_id: str, cert_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + cert = await _cert(vault, cert_id) + if cert is None or not cert.is_ca: + await send_error(ctx, 404, "CA certificate not found") + return + authority = await Authority.get_or_none(certificate_id=cert.id) + if authority is None: + authority = await Authority.create(certificate_id=cert.id, crl_days=30) + await send_json(ctx, 200, { + "certificate_id": str(cert.id), + "crl_days": authority.crl_days, + "crl_number": authority.crl_number, + "crl_expire": authority.crl_expire.isoformat() if authority.crl_expire else None, + "default_template_id": str(authority.template_id) if authority.template_id else None, + "has_tls_auth_key": bool(authority.tls_auth_key), + }) + + @app.PATCH("/api/vaults/${vault_id}/certificates/${cert_id}/ca") + @operation(summary="Update CA properties", tags=["ca"]) + @require_auth(kca.oidc) + async def update_ca(ctx: HttpContext, vault_id: str, cert_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + cert = await _cert(vault, cert_id) + if cert is None or not cert.is_ca: + await send_error(ctx, 404, "CA certificate not found") + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + authority = await Authority.get_or_none(certificate_id=cert.id) + if authority is None: + authority = await Authority.create(certificate_id=cert.id, crl_days=30) + if "crl_days" in body: + try: + days = int(body["crl_days"]) + except (TypeError, ValueError): + await send_error(ctx, 400, "crl_days must be an integer") + return + if days <= 0: + await send_error(ctx, 400, "crl_days must be positive") + return + authority.crl_days = days + if "template_id" in body: + template_id = body.get("template_id") + if template_id: + template = await Template.get_or_none(id=parse_uuid(str(template_id)), vault_id=vault.id) + if template is None: + await send_error(ctx, 400, "template not found") + return + authority.template_id = template.id + else: + authority.template_id = None + await authority.save() + await send_json(ctx, 200, { + "certificate_id": str(cert.id), + "crl_days": authority.crl_days, + "crl_number": authority.crl_number, + "crl_expire": authority.crl_expire.isoformat() if authority.crl_expire else None, + "default_template_id": str(authority.template_id) if authority.template_id else None, + "has_tls_auth_key": bool(authority.tls_auth_key), + }) + + @app.GET("/api/vaults/${vault_id}/certificates/${cert_id}/revocations") + @operation(summary="List CA revocations", tags=["ca"]) + @require_auth(kca.oidc) + async def list_revocations(ctx: HttpContext, vault_id: str, cert_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + cert = await _cert(vault, cert_id) + if cert is None or not cert.is_ca: + await send_error(ctx, 404, "CA certificate not found") + return + rows = await CertificateRevocation.filter(ca_id=cert.id).order_by("-revoked_at") + await send_json(ctx, 200, {"results": [await serialize_revocation(row) for row in rows]}) + + @app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/revocations") + @operation(summary="Add a revocation manually", tags=["ca"]) + @require_auth(kca.oidc) + async def add_revocation(ctx: HttpContext, vault_id: str, cert_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + cert = await _cert(vault, cert_id) + if cert is None or not cert.is_ca: + await send_error(ctx, 404, "CA certificate not found") + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + serial = str(body.get("serial_hex") or "").strip().lower().replace(":", "") + if not serial: + await send_error(ctx, 400, "serial_hex is required") + return + try: + revoked_at = parse_datetime(body.get("revoked_at") or datetime.now(timezone.utc).isoformat()) + invalidity = parse_datetime(body["invalidity_date"]) if body.get("invalidity_date") else None + except ValidationError as exc: + await send_error(ctx, 400, str(exc)) + return + reason = str(body.get("reason") or "unspecified") + if reason not in {item["name"] for item in crl_core.CRL_REASONS}: + await send_error(ctx, 400, "unknown revocation reason") + return + row, created = await CertificateRevocation.get_or_create( + ca_id=cert.id, + serial_hex=serial, + defaults={"revoked_at": revoked_at, "invalidity_date": invalidity, "reason": reason}, + ) + if not created: + await send_error(ctx, 409, "this serial is already revoked") + return + await send_json(ctx, 201, await serialize_revocation(row)) + + @app.PATCH("/api/vaults/${vault_id}/certificates/${cert_id}/revocations/${rev_id}") + @operation(summary="Edit a revocation", tags=["ca"]) + @require_auth(kca.oidc) + async def update_revocation(ctx: HttpContext, vault_id: str, cert_id: str, rev_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + cert = await _cert(vault, cert_id) + if cert is None: + await send_error(ctx, 404, "CA certificate not found") + return + row = await CertificateRevocation.get_or_none(id=parse_uuid(rev_id), ca_id=cert.id) + if row is None: + await send_error(ctx, 404, "revocation not found") + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + try: + if "revoked_at" in body: + row.revoked_at = parse_datetime(body["revoked_at"], "revoked_at") + if "invalidity_date" in body: + row.invalidity_date = ( + parse_datetime(body["invalidity_date"], "invalidity_date") + if body["invalidity_date"] + else None + ) + except ValidationError as exc: + await send_error(ctx, 400, str(exc)) + return + if "reason" in body: + if str(body["reason"]) not in {item["name"] for item in crl_core.CRL_REASONS}: + await send_error(ctx, 400, "unknown revocation reason") + return + row.reason = str(body["reason"]) + await row.save() + await send_json(ctx, 200, await serialize_revocation(row)) + + @app.DELETE("/api/vaults/${vault_id}/certificates/${cert_id}/revocations/${rev_id}") + @operation(summary="Delete a revocation", tags=["ca"]) + @require_auth(kca.oidc) + async def delete_revocation(ctx: HttpContext, vault_id: str, cert_id: str, rev_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + cert = await _cert(vault, cert_id) + if cert is None: + await send_error(ctx, 404, "CA certificate not found") + return + row = await CertificateRevocation.get_or_none(id=parse_uuid(rev_id), ca_id=cert.id) + if row is None: + await send_error(ctx, 404, "revocation not found") + return + await row.delete() + await ctx.send_empty(204) + + @app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/crl") + @operation(summary="Generate a CRL", tags=["ca"]) + @require_auth(kca.oidc) + async def create_crl(ctx: HttpContext, vault_id: str, cert_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + cert = await _cert(vault, cert_id) + if cert is None: + await send_error(ctx, 404, "certificate not found") + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + try: + row = await generate_crl(vault, cert, body) + except (ValidationError, ValueError) as exc: + await send_error(ctx, 400, str(exc)) + return + await send_json(ctx, 201, await serialize_crl_detail(row)) + + @app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/tls-auth") + @operation(summary="Generate or import the CA's OpenVPN tls-auth key", tags=["ca"]) + @require_auth(kca.oidc) + async def tls_auth(ctx: HttpContext, vault_id: str, cert_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + cert = await _cert(vault, cert_id) + if cert is None or not cert.is_ca: + await send_error(ctx, 404, "CA certificate not found") + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + authority = await Authority.get_or_none(certificate_id=cert.id) + if authority is None: + authority = await Authority.create(certificate_id=cert.id, crl_days=30) + raw = None + if body.get("key"): + text = str(body["key"]) + raw = _parse_tls_auth(text) + if raw is None: + await send_error(ctx, 400, "invalid OpenVPN tls-auth key") + return + else: + raw = os.urandom(32) + blob = encrypt_blob(raw, master_key=settings.master_key, aad=str(cert.id).encode()) + authority.tls_auth_key = blob.ciphertext + authority.tls_auth_key_nonce = blob.nonce + await authority.save(update_fields=["tls_auth_key", "tls_auth_key_nonce"]) + await send_json(ctx, 200, {"key": _format_tls_auth(raw)}) + + # ------------------------------------------------------------------ + # Transforms + # ------------------------------------------------------------------ + + @app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/transform/open-key") + @operation(summary="Extract the public key from a certificate", tags=["certificates"]) + @require_auth(kca.oidc) + async def extract_key(ctx: HttpContext, vault_id: str, cert_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + cert = await _cert(vault, cert_id) + if cert is None: + await send_error(ctx, 404, "certificate not found") + return + parsed = cert_core.parse_certificate(bytes(cert.cert_der)) + key = await save_key( + vault, + parsed.public_key(), + name=f"{cert.name} public key", + comment=f"Extracted from certificate {cert.name}", + source="transformed", + ) + await send_json(ctx, 201, {"id": str(key.id), "name": key.name}) + + @app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/transform/request") + @operation(summary="Create a request from a certificate", tags=["certificates"]) + @require_auth(kca.oidc) + async def to_request(ctx: HttpContext, vault_id: str, cert_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + cert = await _cert(vault, cert_id) + if cert is None: + await send_error(ctx, 404, "certificate not found") + return + key = await Key.get_or_none(id=cert.key_id) if cert.key_id else None + if key is None or not key.has_private: + await send_error(ctx, 400, "the certificate has no private key") + return + parsed = cert_core.parse_certificate(bytes(cert.cert_der)) + try: + csr = req_core.build_csr( + subject=parsed.subject, + private_key=await load_private_key(key), + extension_spec=cert_core.certificate_extension_specs(parsed), + signature_hash=cert_core.signature_hash_name(parsed), + ) + except ValueError as exc: + await send_error(ctx, 400, str(exc)) + return + info = req_core.request_info(csr) + row = await Request.create( + vault_id=vault.id, + key_id=key.id, + name=f"{cert.name} request", + comment=f"Transformed from certificate {cert.name}", + source="transformed", + subject_der=info.subject.public_bytes(), + req_der=req_core.csr_der(csr), + public_key_der=info.public_key_der, + request_sha256=info.fingerprint_sha256, + attributes=info.attributes, + search_text=search_text(cert.name, "request", info.subject.rfc4514_string()), + ) + await send_json(ctx, 201, {"id": str(row.id), "name": row.name}) + + @app.POST("/api/vaults/${vault_id}/certificates/${cert_id}/transform/template") + @operation(summary="Create a template from a certificate", tags=["certificates"]) + @require_auth(kca.oidc) + async def to_template(ctx: HttpContext, vault_id: str, cert_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + cert = await _cert(vault, cert_id) + if cert is None: + await send_error(ctx, 404, "certificate not found") + return + spec = template_core.template_from_certificate( + cert_core.parse_certificate(bytes(cert.cert_der)), + cert.name, + f"Transformed from certificate {cert.name}", + ) + template = await Template.create( + vault_id=vault.id, + name=spec["name"], + comment=spec["comment"], + source="transformed", + subject=spec["subject"], + settings=spec["settings"], + search_text=search_text(spec["name"], spec["comment"]), + ) + await send_json(ctx, 201, await serialize_template_summary(template)) + + +def _parse_tls_auth(text: str) -> Optional[bytes]: + begin = "-----BEGIN OpenVPN Static key V1-----" + end = "-----END OpenVPN Static key V1-----" + if begin not in text or end not in text: + return None + body = text.split(begin, 1)[1].split(end, 1)[0] + try: + data = bytes.fromhex("".join(body.split())) + except ValueError: + return None + return data if len(data) == 32 else None + + +def _format_tls_auth(raw: bytes) -> str: + text = raw.hex() + return ( + "-----BEGIN OpenVPN Static key V1-----\n" + + "\n".join(text[i : i + 32] for i in range(0, len(text), 32)) + + "\n-----END OpenVPN Static key V1-----\n" + ) diff --git a/server/packages/kca-app/src/kca/routes/crls.py b/server/packages/kca-app/src/kca/routes/crls.py new file mode 100644 index 0000000..7f3c29b --- /dev/null +++ b/server/packages/kca-app/src/kca/routes/crls.py @@ -0,0 +1,109 @@ +"""Revocation list list/detail/delete/export.""" +from __future__ import annotations + +import base64 +from typing import TYPE_CHECKING, Optional + +from kaya.core import HttpContext, KayaApp +from kaya.openapi import operation + +from kca.core import crls as crl_core +from kca.core import exports as export_core + +from ..auth import authenticated_user, require_auth +from ..http import JsonRequestError, read_json, send_error, send_json +from ..models import Crl +from ..openapi import error_response, json_response, schema_ref +from ..service import ( + parse_uuid, + serialize_crl_detail, + serialize_crl_summary, + vault_for_owner, +) + +if TYPE_CHECKING: + from ..mixin import Kca + + +async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str): + user = await authenticated_user(kca.oidc, ctx) + assert user is not None + vault = await vault_for_owner(user.sub, parse_uuid(vault_id)) + if vault is None: + await send_error(ctx, 404, "vault not found") + return vault + + +async def _crl(vault, crl_id: str) -> Optional[Crl]: + uuid = parse_uuid(crl_id) + return await Crl.get_or_none(id=uuid, vault_id=vault.id) if uuid else None + + +def register(app: KayaApp, kca: "Kca") -> None: + @app.GET("/api/vaults/${vault_id}/crls") + @operation(summary="List revocation lists", tags=["crls"]) + @require_auth(kca.oidc) + async def list_crls(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + rows = await Crl.filter(vault_id=vault.id).order_by("-last_update") + await send_json(ctx, 200, {"results": [await serialize_crl_summary(c) for c in rows]}) + + @app.GET("/api/vaults/${vault_id}/crls/${crl_id}") + @operation(summary="Revocation list detail", tags=["crls"]) + @require_auth(kca.oidc) + async def get_crl(ctx: HttpContext, vault_id: str, crl_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + row = await _crl(vault, crl_id) + if row is None: + await send_error(ctx, 404, "CRL not found") + return + await send_json(ctx, 200, await serialize_crl_detail(row)) + + @app.DELETE("/api/vaults/${vault_id}/crls/${crl_id}") + @operation(summary="Delete a revocation list", tags=["crls"]) + @require_auth(kca.oidc) + async def delete_crl(ctx: HttpContext, vault_id: str, crl_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + row = await _crl(vault, crl_id) + if row is None: + await send_error(ctx, 404, "CRL not found") + return + await row.delete() + await ctx.send_empty(204) + + @app.POST("/api/vaults/${vault_id}/crls/${crl_id}/export") + @operation(summary="Export a revocation list", tags=["crls"]) + @require_auth(kca.oidc) + async def export_crl(ctx: HttpContext, vault_id: str, crl_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + row = await _crl(vault, crl_id) + if row is None: + await send_error(ctx, 404, "CRL not found") + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + try: + result = export_core.export_crl( + crl_core.parse_crl(bytes(row.crl_der)), + str(body.get("format") or "pem"), + name=row.name, + ) + except ValueError as exc: + await send_error(ctx, 400, str(exc)) + return + await send_json(ctx, 200, { + "filename": result.filename, + "content_type": result.content_type, + "content_base64": base64.b64encode(result.data).decode("ascii"), + }) diff --git a/server/packages/kca-app/src/kca/routes/health.py b/server/packages/kca-app/src/kca/routes/health.py new file mode 100644 index 0000000..0fcd32e --- /dev/null +++ b/server/packages/kca-app/src/kca/routes/health.py @@ -0,0 +1,20 @@ +"""Liveness probe.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + +from kaya.core import HttpContext, KayaApp +from kaya.openapi import operation + +from ..openapi import json_response, schema_ref + +if TYPE_CHECKING: + from ..mixin import Kca + + +def register(app: KayaApp, kca: "Kca") -> None: + @app.GET("/api/health") + @operation(summary="Health check", tags=["health"], + responses={200: json_response("The service is up", schema_ref("Health"))}) + async def health(ctx: HttpContext) -> None: + await ctx.send_bytes(200, b'{"status":"ok"}', {"content-type": ("application/json",)}) diff --git a/server/packages/kca-app/src/kca/routes/import_.py b/server/packages/kca-app/src/kca/routes/import_.py new file mode 100644 index 0000000..35fab46 --- /dev/null +++ b/server/packages/kca-app/src/kca/routes/import_.py @@ -0,0 +1,127 @@ +"""Import files, PEM bundles and XCA databases.""" +from __future__ import annotations + +import base64 +import binascii +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from kaya.core import HttpContext, KayaApp +from kaya.openapi import operation + +from kca.core import certificates as cert_core +from kca.core import crls as crl_core +from kca.core import imports as import_core +from kca.core import keys as key_core +from kca.core import requests as req_core + +from ..auth import authenticated_user, require_auth +from ..config import settings +from ..http import JsonRequestError, read_json, send_error, send_json +from ..models import Certificate, Crl, Key, Request +from ..openapi import error_response, json_response, schema_ref +from ..service import ( + import_parsed_items, + parse_uuid, + vault_for_owner, +) + +if TYPE_CHECKING: + from ..mixin import Kca + + +async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str): + user = await authenticated_user(kca.oidc, ctx) + assert user is not None + vault = await vault_for_owner(user.sub, parse_uuid(vault_id)) + if vault is None: + await send_error(ctx, 404, "vault not found") + return vault + + +async def _duplicate(vault, item: import_core.ParsedItem) -> bool: + if item.kind == "key": + info = key_core.key_info(item.obj) + return await Key.filter(vault_id=vault.id, public_key_sha256=info.public_sha256).exists() + if item.kind == "certificate": + info = cert_core.certificate_info(item.obj) + return await Certificate.filter(vault_id=vault.id, fingerprint_sha256=info.fingerprint_sha256).exists() + if item.kind == "request": + info = req_core.request_info(item.obj) + return await Request.filter(vault_id=vault.id, request_sha256=info.fingerprint_sha256).exists() + if item.kind == "crl": + info = crl_core.crl_info(item.obj) + return await Crl.filter(vault_id=vault.id, crl_hash=info.fingerprint_sha256).exists() + return False + + +def register(app: KayaApp, kca: "Kca") -> None: + @app.POST("/api/vaults/${vault_id}/import") + @operation(summary="Import items into a vault", tags=["import"], + responses={200: json_response("Import result", schema_ref("ImportResult")), + 400: error_response("Invalid file, password or selection"), + 401: error_response("Authentication required"), + 404: error_response("Vault not found"), + 413: error_response("File too large")}) + @require_auth(kca.oidc) + async def import_items(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + filename = str(body.get("filename") or "import") + password = body.get("password") + if password is not None and not isinstance(password, str): + await send_error(ctx, 400, "password must be a string") + return + raw: Optional[bytes] = None + if body.get("content_base64"): + try: + raw = base64.b64decode(str(body["content_base64"]), validate=True) + except (binascii.Error, ValueError): + await send_error(ctx, 400, "content_base64 is not valid base64") + return + elif body.get("pem"): + raw = str(body["pem"]).encode("utf-8") + if raw is None: + await send_error(ctx, 400, "content_base64 or pem is required") + return + if len(raw) > settings.max_upload_bytes: + await send_error(ctx, 413, f"the file is larger than {settings.max_upload_bytes} bytes") + return + dry_run = bool(body.get("dry_run")) + use_xca_db = bool(body.get("xca_db")) or filename.lower().endswith(".xdb") + try: + if use_xca_db: + items = import_core.parse_xca_database(raw, filename, password) + else: + items = import_core.parse_file(raw, filename, password) + except ValueError as exc: + await send_error(ctx, 400, str(exc)) + return + if dry_run: + preview = [] + for index, item in enumerate(items): + preview.append( + { + "index": index, + "kind": item.kind, + "name": item.name, + "summary": item.summary, + "is_private": item.is_private, + "duplicate": await _duplicate(vault, item), + "warnings": item.warnings, + } + ) + await send_json(ctx, 200, {"dry_run": True, "items": preview, "count": len(preview)}) + return + selected = body.get("selected") + if selected is not None: + if not isinstance(selected, list) or not all(isinstance(i, int) for i in selected): + await send_error(ctx, 400, "selected must be a list of item indexes") + return + result = await import_parsed_items(vault, items, selected=selected) + await send_json(ctx, 200, result) diff --git a/server/packages/kca-app/src/kca/routes/keys.py b/server/packages/kca-app/src/kca/routes/keys.py new file mode 100644 index 0000000..c120a66 --- /dev/null +++ b/server/packages/kca-app/src/kca/routes/keys.py @@ -0,0 +1,225 @@ +"""Key generation, detail, rename/delete and export.""" +from __future__ import annotations + +import asyncio +import base64 +from typing import TYPE_CHECKING, Optional + +from kaya.core import HttpContext, KayaApp +from kaya.openapi import operation +from tortoise.exceptions import IntegrityError + +from kca.core import exports as export_core +from kca.core import keys as key_core + +from ..auth import authenticated_user, require_auth +from ..http import JsonRequestError, read_json, send_error, send_json +from ..models import Certificate, Key, Request +from ..openapi import error_response, json_response, schema_ref +from ..service import ( + ValidationError, + parse_key_generation, + parse_uuid, + save_key, + serialize_key_detail, + serialize_key_summary, + load_private_key, + vault_for_owner, +) + +if TYPE_CHECKING: + from ..mixin import Kca + +# Key generation is CPU-bound; this bounds concurrent RSA/DSA jobs. +_keygen_semaphore = asyncio.Semaphore(2) + + +async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str): + user = await authenticated_user(kca.oidc, ctx) + assert user is not None + vault = await vault_for_owner(user.sub, parse_uuid(vault_id)) + if vault is None: + await send_error(ctx, 404, "vault not found") + return vault + + +async def _key(vault, key_id: str) -> Optional[Key]: + uuid = parse_uuid(key_id) + return await Key.get_or_none(id=uuid, vault_id=vault.id) if uuid else None + + +def register(app: KayaApp, kca: "Kca") -> None: + @app.GET("/api/vaults/${vault_id}/keys") + @operation(summary="List keys", tags=["keys"], + responses={200: json_response("Keys", schema_ref("KeysPage")), + 401: error_response("Authentication required"), + 404: error_response("Vault not found")}) + @require_auth(kca.oidc) + async def list_keys(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + rows = await Key.filter(vault_id=vault.id).order_by("name", "id") + await send_json(ctx, 200, {"results": [await serialize_key_summary(k) for k in rows]}) + + @app.POST("/api/vaults/${vault_id}/keys") + @operation(summary="Generate a key", tags=["keys"], + responses={201: json_response("Key created", schema_ref("KeyDetail")), + 400: error_response("Invalid key parameters"), + 401: error_response("Authentication required"), + 404: error_response("Vault not found"), + 409: error_response("Key already exists")}) + @require_auth(kca.oidc) + async def generate_key(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + try: + params = parse_key_generation(body) + except ValidationError as exc: + await send_error(ctx, 400, str(exc)) + return + try: + async with _keygen_semaphore: + generated = await asyncio.to_thread( + key_core.generate_key, + params["key_type"], + params["size"], + params["curve"], + ) + key = await save_key( + vault, + generated, + name=params["name"], + comment=params["comment"], + source="generated", + ) + except ValueError as exc: + await send_error(ctx, 400, str(exc)) + return + except IntegrityError: + await send_error(ctx, 409, "this key is already in the vault") + return + await send_json(ctx, 201, await serialize_key_detail(key)) + + @app.GET("/api/vaults/${vault_id}/keys/${key_id}") + @operation(summary="Key detail", tags=["keys"], + responses={200: json_response("Key", schema_ref("KeyDetail")), + 401: error_response("Authentication required"), + 404: error_response("Key not found")}) + @require_auth(kca.oidc) + async def get_key(ctx: HttpContext, vault_id: str, key_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + key = await _key(vault, key_id) + if key is None: + await send_error(ctx, 404, "key not found") + return + await send_json(ctx, 200, await serialize_key_detail(key)) + + @app.PATCH("/api/vaults/${vault_id}/keys/${key_id}") + @operation(summary="Rename a key", tags=["keys"], + responses={200: json_response("Key updated", schema_ref("KeyDetail")), + 400: error_response("Invalid body"), + 401: error_response("Authentication required"), + 404: error_response("Key not found"), + 409: error_response("Stale version")}) + @require_auth(kca.oidc) + async def update_key(ctx: HttpContext, vault_id: str, key_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + key = await _key(vault, key_id) + if key is None: + await send_error(ctx, 404, "key not found") + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + if "version" in body and int(body["version"]) != key.version: + await send_error(ctx, 409, "the key changed since it was loaded") + return + if "name" in body: + if not isinstance(body["name"], str) or not body["name"].strip(): + await send_error(ctx, 400, "name must not be empty") + return + key.name = body["name"].strip()[:200] + if "comment" in body: + if not isinstance(body["comment"], str): + await send_error(ctx, 400, "comment must be a string") + return + key.comment = body["comment"][:8000] + key.version += 1 + await key.save() + await send_json(ctx, 200, await serialize_key_detail(key)) + + @app.DELETE("/api/vaults/${vault_id}/keys/${key_id}") + @operation(summary="Delete a key", tags=["keys"], + responses={204: {"description": "Deleted"}, + 401: error_response("Authentication required"), + 404: error_response("Key not found")}) + @require_auth(kca.oidc) + async def delete_key(ctx: HttpContext, vault_id: str, key_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + key = await _key(vault, key_id) + if key is None: + await send_error(ctx, 404, "key not found") + return + await Certificate.filter(key_id=key.id).update(key_id=None) + await Request.filter(key_id=key.id).update(key_id=None) + await key.delete() + await ctx.send_empty(204) + + @app.POST("/api/vaults/${vault_id}/keys/${key_id}/export") + @operation(summary="Export a key", tags=["keys"]) + @require_auth(kca.oidc) + async def export_key(ctx: HttpContext, vault_id: str, key_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + key = await _key(vault, key_id) + if key is None: + await send_error(ctx, 404, "key not found") + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + fmt = str(body.get("format") or "pem-public") + password = body.get("password") + if password is not None and not isinstance(password, str): + await send_error(ctx, 400, "password must be a string") + return + try: + if fmt in ("pem-private", "pem", "pem-encrypted", "der-private", "pkcs8", "pkcs8-private", "pkcs8-encrypted", "ssh-private", "jwk-private", "pvk"): + key_obj = await load_private_key(key) + is_private = True + else: + key_obj = key_core.public_key_from_der(bytes(key.public_der)) + is_private = False + result = export_core.export_key( + key_obj, + fmt, + name=key.name, + password=password, + is_private=is_private, + ) + except ValueError as exc: + await send_error(ctx, 400, str(exc)) + return + await send_json(ctx, 200, { + "filename": result.filename, + "content_type": result.content_type, + "content_base64": base64.b64encode(result.data).decode("ascii"), + }) diff --git a/server/packages/kca-app/src/kca/routes/me.py b/server/packages/kca-app/src/kca/routes/me.py new file mode 100644 index 0000000..c241785 --- /dev/null +++ b/server/packages/kca-app/src/kca/routes/me.py @@ -0,0 +1,36 @@ +"""Whoami endpoint.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict + +from kaya.core import HttpContext, KayaApp +from kaya.openapi import operation + +from ..auth import PROFILE_CLAIMS, authenticated_user, require_auth +from ..http import send_json +from ..openapi import error_response, json_response, schema_ref +from ..config import settings + +if TYPE_CHECKING: + from ..mixin import Kca + + +def _serialize(user: Any) -> Dict[str, Any]: + payload: Dict[str, Any] = {"sub": user.sub, "display_name": user.display_name} + for claim in PROFILE_CLAIMS: + payload[claim] = getattr(user, claim) + return payload + + +def register(app: KayaApp, kca: "Kca") -> None: + @app.GET("/api/me") + @operation(summary="Current user", tags=["auth"], + responses={200: json_response("The current user", schema_ref("Me")), + 401: error_response("Not logged in")}) + @require_auth(kca.oidc) + async def me(ctx: HttpContext) -> None: + user = await authenticated_user(kca.oidc, ctx) + assert user is not None + payload = _serialize(user) + payload["master_key_is_dev"] = settings.master_key_is_dev + await send_json(ctx, 200, payload) diff --git a/server/packages/kca-app/src/kca/routes/requests.py b/server/packages/kca-app/src/kca/routes/requests.py new file mode 100644 index 0000000..69b5b8f --- /dev/null +++ b/server/packages/kca-app/src/kca/routes/requests.py @@ -0,0 +1,280 @@ +"""Certificate signing requests: list, create, detail, export and transforms.""" +from __future__ import annotations + +import asyncio +import base64 +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Optional + +from kaya.core import HttpContext, KayaApp +from kaya.openapi import operation +from tortoise.exceptions import IntegrityError + +from kca.core import exports as export_core +from kca.core import keys as key_core +from kca.core import requests as req_core +from kca.core import templates as template_core + +from ..auth import authenticated_user, require_auth +from ..http import JsonRequestError, read_json, send_error, send_json +from ..models import Key, Request, Template +from ..openapi import error_response, json_response, schema_ref +from ..service import ( + ValidationError, + parse_extensions, + parse_subject, + parse_uuid, + save_key, + search_text, + serialize_request_detail, + serialize_request_summary, + serialize_template_summary, + load_private_key, + vault_for_owner, +) + +if TYPE_CHECKING: + from ..mixin import Kca + +_keygen_semaphore = asyncio.Semaphore(2) + + +async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str): + user = await authenticated_user(kca.oidc, ctx) + assert user is not None + vault = await vault_for_owner(user.sub, parse_uuid(vault_id)) + if vault is None: + await send_error(ctx, 404, "vault not found") + return vault + + +async def _request(vault, request_id: str) -> Optional[Request]: + uuid = parse_uuid(request_id) + return await Request.get_or_none(id=uuid, vault_id=vault.id) if uuid else None + + +def register(app: KayaApp, kca: "Kca") -> None: + @app.GET("/api/vaults/${vault_id}/requests") + @operation(summary="List certificate signing requests", tags=["requests"]) + @require_auth(kca.oidc) + async def list_requests(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + rows = await Request.filter(vault_id=vault.id).order_by("name", "id") + await send_json(ctx, 200, {"results": [await serialize_request_summary(r) for r in rows]}) + + @app.POST("/api/vaults/${vault_id}/requests") + @operation(summary="Create a certificate signing request", tags=["requests"]) + @require_auth(kca.oidc) + async def create_request(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + name = str(body.get("name") or "").strip() + if not name: + await send_error(ctx, 400, "name is required") + return + comment = str(body.get("comment") or "") + try: + subject = parse_subject(body.get("subject") or []) + extensions = parse_extensions(body.get("extensions")) + if not subject.rdns: + raise ValidationError("the subject must not be empty") + key_model = None + key_obj = None + if body.get("key_id"): + uuid = parse_uuid(str(body["key_id"])) + key_model = await Key.get_or_none(id=uuid, vault_id=vault.id) if uuid else None + if key_model is None: + raise ValidationError("key not found") + if not key_model.has_private: + raise ValidationError("the selected key has no private part") + key_obj = await load_private_key(key_model) + elif isinstance(body.get("new_key"), dict): + new_key = body["new_key"] + async with _keygen_semaphore: + generated = await asyncio.to_thread( + key_core.generate_key, + str(new_key.get("key_type") or "rsa"), + int(new_key.get("size") or key_core.DEFAULT_KEY_SIZE), + new_key.get("curve"), + ) + key_model = await save_key( + vault, + generated, + name=str(new_key.get("name") or name), + comment="Generated while creating a request", + source="generated", + ) + key_obj = generated + else: + raise ValidationError("select an existing key or generate a new one") + csr = req_core.build_csr( + subject=subject, + private_key=key_obj, + extension_spec=extensions, + signature_hash=body.get("signature_hash"), + attributes=body.get("attributes") or {}, + ) + except (ValidationError, ValueError) as exc: + await send_error(ctx, 400, str(exc)) + return + info = req_core.request_info(csr) + try: + row = await Request.create( + vault_id=vault.id, + key_id=key_model.id if key_model else None, + name=name, + comment=comment, + source="generated", + subject_der=info.subject.public_bytes(), + req_der=req_core.csr_der(csr), + public_key_der=info.public_key_der, + request_sha256=info.fingerprint_sha256, + attributes=info.attributes, + search_text=search_text(name, comment, info.subject.rfc4514_string(), info.fingerprint_sha256), + ) + except IntegrityError: + await send_error(ctx, 409, "this request is already in the vault") + return + await send_json(ctx, 201, await serialize_request_detail(row)) + + @app.GET("/api/vaults/${vault_id}/requests/${request_id}") + @operation(summary="Request detail", tags=["requests"]) + @require_auth(kca.oidc) + async def get_request(ctx: HttpContext, vault_id: str, request_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + row = await _request(vault, request_id) + if row is None: + await send_error(ctx, 404, "request not found") + return + await send_json(ctx, 200, await serialize_request_detail(row)) + + @app.PATCH("/api/vaults/${vault_id}/requests/${request_id}") + @operation(summary="Update a request", tags=["requests"]) + @require_auth(kca.oidc) + async def update_request(ctx: HttpContext, vault_id: str, request_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + row = await _request(vault, request_id) + if row is None: + await send_error(ctx, 404, "request not found") + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + if "version" in body and int(body["version"]) != row.version: + await send_error(ctx, 409, "the request changed since it was loaded") + return + if "name" in body: + if not isinstance(body["name"], str) or not body["name"].strip(): + await send_error(ctx, 400, "name must not be empty") + return + row.name = body["name"].strip()[:200] + if "comment" in body: + row.comment = str(body["comment"])[:8000] + row.version += 1 + await row.save() + await send_json(ctx, 200, await serialize_request_detail(row)) + + @app.DELETE("/api/vaults/${vault_id}/requests/${request_id}") + @operation(summary="Delete a request", tags=["requests"]) + @require_auth(kca.oidc) + async def delete_request(ctx: HttpContext, vault_id: str, request_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + row = await _request(vault, request_id) + if row is None: + await send_error(ctx, 404, "request not found") + return + await row.delete() + await ctx.send_empty(204) + + @app.POST("/api/vaults/${vault_id}/requests/${request_id}/export") + @operation(summary="Export a request", tags=["requests"]) + @require_auth(kca.oidc) + async def export_request(ctx: HttpContext, vault_id: str, request_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + row = await _request(vault, request_id) + if row is None: + await send_error(ctx, 404, "request not found") + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + try: + result = export_core.export_request( + req_core.parse_csr(bytes(row.req_der)), + str(body.get("format") or "pem"), + name=row.name, + ) + except ValueError as exc: + await send_error(ctx, 400, str(exc)) + return + await send_json(ctx, 200, { + "filename": result.filename, + "content_type": result.content_type, + "content_base64": base64.b64encode(result.data).decode("ascii"), + }) + + @app.POST("/api/vaults/${vault_id}/requests/${request_id}/transform/open-key") + @operation(summary="Extract the public key from a request", tags=["requests"]) + @require_auth(kca.oidc) + async def extract_key(ctx: HttpContext, vault_id: str, request_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + row = await _request(vault, request_id) + if row is None: + await send_error(ctx, 404, "request not found") + return + public_key = req_core.csr_public_key(req_core.parse_csr(bytes(row.req_der))) + key = await save_key( + vault, + public_key, + name=f"{row.name} public key", + comment=f"Extracted from request {row.name}", + source="transformed", + ) + await send_json(ctx, 201, {"id": str(key.id), "name": key.name}) + + @app.POST("/api/vaults/${vault_id}/requests/${request_id}/transform/template") + @operation(summary="Create a template from a request", tags=["requests"]) + @require_auth(kca.oidc) + async def to_template(ctx: HttpContext, vault_id: str, request_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + row = await _request(vault, request_id) + if row is None: + await send_error(ctx, 404, "request not found") + return + spec = template_core.template_from_request( + req_core.parse_csr(bytes(row.req_der)), row.name, f"Transformed from request {row.name}" + ) + template = await Template.create( + vault_id=vault.id, + name=spec["name"], + comment=spec["comment"], + source="transformed", + subject=spec["subject"], + settings=spec["settings"], + search_text=search_text(spec["name"], spec["comment"]), + ) + await send_json(ctx, 201, await serialize_template_summary(template)) diff --git a/server/packages/kca-app/src/kca/routes/search.py b/server/packages/kca-app/src/kca/routes/search.py new file mode 100644 index 0000000..3559bce --- /dev/null +++ b/server/packages/kca-app/src/kca/routes/search.py @@ -0,0 +1,75 @@ +"""Global search across a vault's items.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from kaya.core import HttpContext, KayaApp +from kaya.openapi import operation + +from ..auth import authenticated_user, require_auth +from ..http import extract_query_params, send_error, send_json +from ..models import Certificate, Crl, Key, Request, Template +from ..openapi import error_response, json_response, schema_ref +from ..service import ( + parse_uuid, + serialize_certificate_summary, + serialize_crl_summary, + serialize_key_summary, + serialize_request_summary, + serialize_template_summary, + vault_for_owner, +) + +if TYPE_CHECKING: + from ..mixin import Kca + + +def register(app: KayaApp, kca: "Kca") -> None: + @app.GET("/api/vaults/${vault_id}/search") + @operation(summary="Search a vault", tags=["search"], + responses={200: json_response("Search results", schema_ref("SearchPage")), + 401: error_response("Authentication required"), + 404: error_response("Vault not found")}) + @require_auth(kca.oidc) + async def search(ctx: HttpContext, vault_id: str) -> None: + user = await authenticated_user(kca.oidc, ctx) + assert user is not None + vault = await vault_for_owner(user.sub, parse_uuid(vault_id)) + if vault is None: + await send_error(ctx, 404, "vault not found") + return + params = extract_query_params(ctx.query_string) + query = (params.get("q", [""])[0] or "").strip().lower() + kind = (params.get("type", [""])[0] or "").strip().lower() + if not query: + await send_json(ctx, 200, {"results": [], "counts": {}}) + return + results: List[Dict[str, Any]] = [] + counts: Dict[str, int] = {} + + if kind in ("", "all", "certificate", "certificates"): + rows = await Certificate.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100) + counts["certificates"] = len(rows) + for row in rows: + results.append({"type": "certificate", "item": await serialize_certificate_summary(row)}) + if kind in ("", "all", "key", "keys"): + rows = await Key.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100) + counts["keys"] = len(rows) + for row in rows: + results.append({"type": "key", "item": await serialize_key_summary(row)}) + if kind in ("", "all", "request", "requests"): + rows = await Request.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100) + counts["requests"] = len(rows) + for row in rows: + results.append({"type": "request", "item": await serialize_request_summary(row)}) + if kind in ("", "all", "crl", "crls"): + rows = await Crl.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100) + counts["crls"] = len(rows) + for row in rows: + results.append({"type": "crl", "item": await serialize_crl_summary(row)}) + if kind in ("", "all", "template", "templates"): + rows = await Template.filter(vault_id=vault.id, search_text__icontains=query).order_by("name").limit(100) + counts["templates"] = len(rows) + for row in rows: + results.append({"type": "template", "item": await serialize_template_summary(row)}) + await send_json(ctx, 200, {"results": results, "counts": counts}) diff --git a/server/packages/kca-app/src/kca/routes/templates.py b/server/packages/kca-app/src/kca/routes/templates.py new file mode 100644 index 0000000..97e2301 --- /dev/null +++ b/server/packages/kca-app/src/kca/routes/templates.py @@ -0,0 +1,229 @@ +"""Certificate/request templates, including the built-in XCA presets.""" +from __future__ import annotations + +import base64 +from typing import TYPE_CHECKING, Any, Dict, Mapping, Optional, Sequence + +from kaya.core import HttpContext, KayaApp +from kaya.openapi import operation + +from kca.core import exports as export_core +from kca.core import templates as template_core + +from ..auth import authenticated_user, require_auth +from ..http import JsonRequestError, read_json, send_error, send_json +from ..models import Template +from ..openapi import error_response, json_response, schema_ref +from ..service import ( + ValidationError, + parse_uuid, + search_text, + serialize_template_detail, + serialize_template_summary, + vault_for_owner, +) + +if TYPE_CHECKING: + from ..mixin import Kca + + +async def _vault(kca: "Kca", ctx: HttpContext, vault_id: str): + user = await authenticated_user(kca.oidc, ctx) + assert user is not None + vault = await vault_for_owner(user.sub, parse_uuid(vault_id)) + if vault is None: + await send_error(ctx, 404, "vault not found") + return vault + + +async def _template(vault, template_id: str): + for builtin in template_core.builtin_templates(): + if builtin.id == template_id: + return builtin.as_dict() + uuid = parse_uuid(template_id) + return await Template.get_or_none(id=uuid, vault_id=vault.id) if uuid else None + + +def _validate_template_body(body: Mapping[str, Any]) -> Dict[str, Any]: + name = body.get("name") + if not isinstance(name, str) or not name.strip(): + raise ValidationError("name is required") + subject = body.get("subject") or [] + if not isinstance(subject, list): + raise ValidationError("subject must be a list") + settings = body.get("settings") or {} + if not isinstance(settings, dict): + raise ValidationError("settings must be an object") + return { + "name": name.strip()[:200], + "comment": str(body.get("comment") or "")[:8000], + "subject": subject, + "settings": settings, + } + + +def register(app: KayaApp, kca: "Kca") -> None: + @app.GET("/api/vaults/${vault_id}/templates") + @operation(summary="List templates", tags=["templates"]) + @require_auth(kca.oidc) + async def list_templates(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + rows = await Template.filter(vault_id=vault.id).order_by("name", "id") + results = [await serialize_template_summary(t) for t in template_core.builtin_templates()] + results.extend([await serialize_template_summary(t) for t in rows]) + await send_json(ctx, 200, {"results": results}) + + @app.POST("/api/vaults/${vault_id}/templates") + @operation(summary="Create a template", tags=["templates"]) + @require_auth(kca.oidc) + async def create_template(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + try: + values = _validate_template_body(body) + except ValidationError as exc: + await send_error(ctx, 400, str(exc)) + return + row = await Template.create( + vault_id=vault.id, + name=values["name"], + comment=values["comment"], + source="generated", + subject=values["subject"], + settings=values["settings"], + search_text=search_text(values["name"], values["comment"]), + ) + await send_json(ctx, 201, await serialize_template_detail(row)) + + @app.GET("/api/vaults/${vault_id}/templates/${template_id}") + @operation(summary="Template detail", tags=["templates"]) + @require_auth(kca.oidc) + async def get_template(ctx: HttpContext, vault_id: str, template_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + row = await _template(vault, template_id) + if row is None: + await send_error(ctx, 404, "template not found") + return + await send_json(ctx, 200, await serialize_template_detail(row)) + + @app.PATCH("/api/vaults/${vault_id}/templates/${template_id}") + @operation(summary="Update a template", tags=["templates"]) + @require_auth(kca.oidc) + async def update_template(ctx: HttpContext, vault_id: str, template_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + row = await Template.get_or_none(id=parse_uuid(template_id), vault_id=vault.id) + if row is None: + await send_error(ctx, 404, "template not found (built-in templates cannot be edited)") + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + if "version" in body and int(body["version"]) != row.version: + await send_error(ctx, 409, "the template changed since it was loaded") + return + try: + values = _validate_template_body({**{ + "name": row.name, + "comment": row.comment, + "subject": row.subject, + "settings": row.settings, + }, **body}) + except ValidationError as exc: + await send_error(ctx, 400, str(exc)) + return + row.name = values["name"] + row.comment = values["comment"] + row.subject = values["subject"] + row.settings = values["settings"] + row.version += 1 + await row.save() + await send_json(ctx, 200, await serialize_template_detail(row)) + + @app.DELETE("/api/vaults/${vault_id}/templates/${template_id}") + @operation(summary="Delete a template", tags=["templates"]) + @require_auth(kca.oidc) + async def delete_template(ctx: HttpContext, vault_id: str, template_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + if template_id.startswith("builtin:"): + await send_error(ctx, 400, "built-in templates cannot be deleted") + return + row = await Template.get_or_none(id=parse_uuid(template_id), vault_id=vault.id) + if row is None: + await send_error(ctx, 404, "template not found") + return + await row.delete() + await ctx.send_empty(204) + + @app.POST("/api/vaults/${vault_id}/templates/${template_id}/apply") + @operation(summary="Apply a template to a certificate/request spec", tags=["templates"]) + @require_auth(kca.oidc) + async def apply_template(ctx: HttpContext, vault_id: str, template_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + row = await _template(vault, template_id) + if row is None: + await send_error(ctx, 404, "template not found") + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + template = await serialize_template_detail(row) + target = body.get("target") or {} + merged = template_core.apply_template( + template, + target, + apply_subject=bool(body.get("apply_subject", True)), + apply_extensions=bool(body.get("apply_extensions", True)), + ) + await send_json(ctx, 200, merged) + + @app.POST("/api/vaults/${vault_id}/templates/${template_id}/export") + @operation(summary="Export a template", tags=["templates"]) + @require_auth(kca.oidc) + async def export_template(ctx: HttpContext, vault_id: str, template_id: str) -> None: + vault = await _vault(kca, ctx, vault_id) + if vault is None: + return + row = await _template(vault, template_id) + if row is None: + await send_error(ctx, 404, "template not found") + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + template = await serialize_template_detail(row) + try: + result = export_core.export_template( + template, + str(body.get("format") or "xca"), + name=str(template.get("name") or "template"), + ) + except ValueError as exc: + await send_error(ctx, 400, str(exc)) + return + await send_json(ctx, 200, { + "filename": result.filename, + "content_type": result.content_type, + "content_base64": base64.b64encode(result.data).decode("ascii"), + }) diff --git a/server/packages/kca-app/src/kca/routes/tokens.py b/server/packages/kca-app/src/kca/routes/tokens.py new file mode 100644 index 0000000..5d1c98f --- /dev/null +++ b/server/packages/kca-app/src/kca/routes/tokens.py @@ -0,0 +1,98 @@ +"""Personal access tokens for programmatic API access.""" +from __future__ import annotations + +from logging import getLogger +from typing import TYPE_CHECKING, Optional +from uuid import UUID + +from kaya.core import HttpContext, KayaApp +from kaya.openapi import operation + +from ..auth import authenticated_user, require_session_auth +from ..http import JsonRequestError, read_json, send_error, send_json +from ..models import ApiToken +from ..openapi import error_response, json_response, schema_ref +from ..tokens import ( + MAX_TOKENS_PER_USER, + TokenValidationError, + generate_token, + parse_token_payload, + serialize_token, +) + +if TYPE_CHECKING: + from ..mixin import Kca + +log = getLogger(__name__) + + +def _parse_uuid(value: str) -> Optional[UUID]: + try: + return UUID(value) + except (ValueError, AttributeError, TypeError): + return None + + +def register(app: KayaApp, kca: "Kca") -> None: + @app.GET("/api/tokens") + @operation(summary="List my API tokens", tags=["auth"], + responses={200: json_response("The caller's tokens", schema_ref("ApiTokensPage")), + 401: error_response("Authentication required")}) + @require_session_auth(kca.oidc) + async def list_tokens(ctx: HttpContext) -> None: + user = await authenticated_user(kca.oidc, ctx) + assert user is not None + rows = await ApiToken.filter(user_sub=user.sub) + await send_json(ctx, 200, {"results": [serialize_token(token) for token in rows]}) + + @app.POST("/api/tokens") + @operation(summary="Create an API token", tags=["auth"], + responses={201: json_response("Token created", schema_ref("CreatedApiToken")), + 400: error_response("Invalid body or limit reached"), + 401: error_response("Authentication required")}) + @require_session_auth(kca.oidc) + async def create_token(ctx: HttpContext) -> None: + user = await authenticated_user(kca.oidc, ctx) + assert user is not None + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + try: + values = parse_token_payload(body) + except TokenValidationError as exc: + await send_error(ctx, 400, str(exc)) + return + if await ApiToken.filter(user_sub=user.sub).count() >= MAX_TOKENS_PER_USER: + await send_error(ctx, 400, f"you already have {MAX_TOKENS_PER_USER} tokens; revoke unused ones first") + return + secret, prefix, digest = generate_token() + token = await ApiToken.create( + user_sub=user.sub, + name=values["name"], + token_prefix=prefix, + token_hash=digest, + expires_at=values["expires_at"], + ) + log.info("created api token sub=%s name=%r prefix=%s", user.sub, token.name, token.token_prefix) + payload = serialize_token(token) + payload["token"] = secret + await send_json(ctx, 201, payload) + + @app.DELETE("/api/tokens/${token_id}") + @operation(summary="Revoke an API token", tags=["auth"], + responses={204: {"description": "Revoked"}, + 401: error_response("Authentication required"), + 404: error_response("No such token")}) + @require_session_auth(kca.oidc) + async def revoke_token(ctx: HttpContext, token_id: str) -> None: + user = await authenticated_user(kca.oidc, ctx) + assert user is not None + token_uuid = _parse_uuid(token_id) + token = await ApiToken.get_or_none(id=token_uuid, user_sub=user.sub) if token_uuid else None + if token is None: + await send_error(ctx, 404, "token not found") + return + await ApiToken.filter(id=token.id).delete() + await ctx.send_empty(204) diff --git a/server/packages/kca-app/src/kca/routes/vaults.py b/server/packages/kca-app/src/kca/routes/vaults.py new file mode 100644 index 0000000..b8586c1 --- /dev/null +++ b/server/packages/kca-app/src/kca/routes/vaults.py @@ -0,0 +1,247 @@ +"""Vaults (XCA databases) and their settings.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, Any, Dict, List, Optional + +from kaya.core import HttpContext, KayaApp +from kaya.openapi import operation + +from ..auth import authenticated_user, require_auth +from ..http import JsonRequestError, read_json, send_error, send_json +from ..models import Certificate, Crl, Key, Request, Template, Vault +from ..openapi import error_response, json_response, schema_ref +from ..service import ( + DEFAULT_VAULT_SETTINGS, + ValidationError, + iso, + merge_settings, + parse_uuid, + serialize_certificate_summary, + serialize_crl_summary, + serialize_key_summary, + serialize_request_summary, + serialize_template_summary, + serialize_vault_detail, + serialize_vault_summary, + vault_for_owner, + vault_settings, +) +from ..core import crls as crl_core +from ..core import templates as template_core +from ..core import extensions as ext_core +from ..core import keys as key_core +from ..core import names as name_core + +if TYPE_CHECKING: + from ..mixin import Kca + + +def _parse_vault_id(value: str): + return parse_uuid(value) + + +async def _vault_or_error(kca: "Kca", ctx: HttpContext, vault_id: str) -> Optional[Vault]: + user = await authenticated_user(kca.oidc, ctx) + assert user is not None + vault = await vault_for_owner(user.sub, _parse_vault_id(vault_id)) + if vault is None: + await send_error(ctx, 404, "vault not found") + return vault + + +def register(app: KayaApp, kca: "Kca") -> None: + @app.GET("/api/vaults") + @operation(summary="List my vaults", tags=["vaults"], + responses={200: json_response("Vaults", schema_ref("VaultsPage")), + 401: error_response("Authentication required")}) + @require_auth(kca.oidc) + async def list_vaults(ctx: HttpContext) -> None: + user = await authenticated_user(kca.oidc, ctx) + assert user is not None + vaults = await Vault.filter(owner_sub=user.sub).order_by("name", "id") + await send_json(ctx, 200, {"results": [await serialize_vault_summary(v) for v in vaults]}) + + @app.POST("/api/vaults") + @operation(summary="Create a vault", tags=["vaults"], + responses={201: json_response("Vault created", schema_ref("VaultDetail")), + 400: error_response("Invalid body"), + 401: error_response("Authentication required")}) + @require_auth(kca.oidc) + async def create_vault(ctx: HttpContext) -> None: + user = await authenticated_user(kca.oidc, ctx) + assert user is not None + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + name = body.get("name") + if not isinstance(name, str) or not name.strip(): + await send_error(ctx, 400, "name is required") + return + description = body.get("description", "") + if not isinstance(description, str): + await send_error(ctx, 400, "description must be a string") + return + try: + settings_values = dict(DEFAULT_VAULT_SETTINGS) + if isinstance(body.get("settings"), dict): + settings_values = merge_settings(settings_values, body["settings"]) + except ValidationError as exc: + await send_error(ctx, 400, str(exc)) + return + vault = await Vault.create( + owner_sub=user.sub, + name=name.strip()[:200], + description=description[:4000], + settings=settings_values, + ) + await send_json(ctx, 201, await serialize_vault_detail(vault)) + + @app.GET("/api/vaults/${vault_id}") + @operation(summary="Vault detail", tags=["vaults"], + responses={200: json_response("Vault", schema_ref("VaultDetail")), + 401: error_response("Authentication required"), + 404: error_response("Vault not found")}) + @require_auth(kca.oidc) + async def get_vault(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault_or_error(kca, ctx, vault_id) + if vault is None: + return + await send_json(ctx, 200, await serialize_vault_detail(vault)) + + @app.PATCH("/api/vaults/${vault_id}") + @operation(summary="Update a vault", tags=["vaults"], + responses={200: json_response("Vault updated", schema_ref("VaultDetail")), + 400: error_response("Invalid body"), + 401: error_response("Authentication required"), + 404: error_response("Vault not found"), + 409: error_response("Stale version")}) + @require_auth(kca.oidc) + async def update_vault(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault_or_error(kca, ctx, vault_id) + if vault is None: + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + if "version" in body and int(body["version"]) != vault.version: + await send_error(ctx, 409, "the vault changed since it was loaded") + return + if "name" in body: + if not isinstance(body["name"], str) or not body["name"].strip(): + await send_error(ctx, 400, "name must not be empty") + return + vault.name = body["name"].strip()[:200] + if "description" in body: + if not isinstance(body["description"], str): + await send_error(ctx, 400, "description must be a string") + return + vault.description = body["description"][:4000] + vault.version += 1 + await vault.save() + await send_json(ctx, 200, await serialize_vault_detail(vault)) + + @app.DELETE("/api/vaults/${vault_id}") + @operation(summary="Delete a vault", tags=["vaults"], + responses={204: {"description": "Deleted"}, + 401: error_response("Authentication required"), + 404: error_response("Vault not found")}) + @require_auth(kca.oidc) + async def delete_vault(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault_or_error(kca, ctx, vault_id) + if vault is None: + return + await Vault.filter(id=vault.id).delete() + await ctx.send_empty(204) + + @app.GET("/api/vaults/${vault_id}/settings") + @operation(summary="Vault settings", tags=["vaults"]) + @require_auth(kca.oidc) + async def get_settings(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault_or_error(kca, ctx, vault_id) + if vault is None: + return + await send_json(ctx, 200, {"settings": vault_settings(vault), "version": vault.version}) + + @app.PATCH("/api/vaults/${vault_id}/settings") + @operation(summary="Update vault settings", tags=["vaults"]) + @require_auth(kca.oidc) + async def update_settings(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault_or_error(kca, ctx, vault_id) + if vault is None: + return + try: + body = await read_json(ctx) + except JsonRequestError as exc: + await send_error(ctx, 400, str(exc)) + return + if "version" in body and int(body["version"]) != vault.version: + await send_error(ctx, 409, "the vault changed since it was loaded") + return + patch = body.get("settings", body) + if not isinstance(patch, dict): + await send_error(ctx, 400, "settings must be an object") + return + try: + vault.settings = merge_settings(vault_settings(vault), patch) + except ValidationError as exc: + await send_error(ctx, 400, str(exc)) + return + vault.version += 1 + await vault.save() + await send_json(ctx, 200, {"settings": vault_settings(vault), "version": vault.version}) + + @app.GET("/api/vaults/${vault_id}/summary") + @operation(summary="Vault dashboard summary", tags=["vaults"]) + @require_auth(kca.oidc) + async def vault_summary(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault_or_error(kca, ctx, vault_id) + if vault is None: + return + now = datetime.now(timezone.utc) + expiring = await Certificate.filter( + vault_id=vault.id, not_after__gte=now, not_after__lte=now + timedelta(days=30) + ).order_by("not_after").limit(10) + due_crls = await Crl.filter( + vault_id=vault.id, next_update__lte=now + timedelta(days=7) + ).order_by("next_update").limit(10) + recent_certs = await Certificate.filter(vault_id=vault.id).order_by("-created_at").limit(8) + recent_keys = await Key.filter(vault_id=vault.id).order_by("-created_at").limit(8) + await send_json(ctx, 200, { + "summary": await serialize_vault_summary(vault), + "expiring": [await serialize_certificate_summary(c) for c in expiring], + "crls_due": [await serialize_crl_summary(c) for c in due_crls], + "recent_certificates": [await serialize_certificate_summary(c) for c in recent_certs], + "recent_keys": [await serialize_key_summary(k) for k in recent_keys], + }) + + @app.GET("/api/vaults/${vault_id}/options") + @operation(summary="Reference data for editors", tags=["vaults"]) + @require_auth(kca.oidc) + async def options(ctx: HttpContext, vault_id: str) -> None: + vault = await _vault_or_error(kca, ctx, vault_id) + if vault is None: + return + await send_json(ctx, 200, { + "key_types": key_core.key_type_options(), + "hash_algorithms": list(key_core.HASH_ALGORITHMS), + "curves": list(key_core.EC_CURVES), + "dn_attributes": name_core.name_attribute_options(), + "eku": name_core.eku_options(), + "key_usages": ext_core.KEY_USAGES, + "revocation_reasons": crl_core.reason_options(), + "defaults": vault_settings(vault), + "names": { + "extensions": ext_core.EXTENSION_NAMES, + "general_name_types": list(ext_core.GENERAL_NAME_TYPES), + "netscape_cert_types": [name for name, _bit in ext_core.NETSCAPE_CERT_TYPES], + }, + "templates": [ + *[await serialize_template_summary(t) for t in template_core.builtin_templates()], + *[await serialize_template_summary(t) for t in await Template.filter(vault_id=vault.id)], + ], + }) diff --git a/server/packages/kca-app/src/kca/service.py b/server/packages/kca-app/src/kca/service.py new file mode 100644 index 0000000..83d4eee --- /dev/null +++ b/server/packages/kca-app/src/kca/service.py @@ -0,0 +1,1278 @@ +"""Business logic: validation, persistence, serialization and CA operations.""" +from __future__ import annotations + +import hashlib +import secrets +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple +from uuid import UUID + +from cryptography import x509 +from tortoise.expressions import Q + +from kca.core import certificates as cert_core +from kca.core import crls as crl_core +from kca.core import extensions as ext_core +from kca.core import keys as key_core +from kca.core import names as name_core +from kca.core import requests as req_core +from kca.core import templates as template_core +from kca.core.crypto import EncryptedBlob, decrypt_private_key, encrypt_blob, encrypt_private_key +from kca.core.imports import ParsedItem +from kca.core.names import DnEntry + +from .config import settings +from .models import ( + ApiToken, + Authority, + Certificate, + CertificateRevocation, + Crl, + Key, + Request, + Template, + User, + Vault, +) + + +class ValidationError(ValueError): + """A user-visible 400 error.""" + + +DEFAULT_VAULT_SETTINGS: Dict[str, Any] = { + "default_hash": "sha256", + "serial_len_bits": 64, + "explicit_dn": ["C", "ST", "L", "O", "OU", "CN", "emailAddress"], + "mandatory_dn": [], + "adapt_explicit_subject": False, + "disable_netscape": False, + "hide_unusable": False, + "cert_expiry": {"value": 80, "unit": "%"}, + "default_key": {"type": "rsa", "size": 2048, "curve": None}, + "default_validity": {"amount": 365, "unit": "days"}, +} + +SETTING_KEYS = set(DEFAULT_VAULT_SETTINGS) + + +def merge_settings(current: Mapping[str, Any], patch: Mapping[str, Any]) -> Dict[str, Any]: + merged = dict(DEFAULT_VAULT_SETTINGS) + merged.update(dict(current or {})) + for key, value in patch.items(): + if key not in SETTING_KEYS: + raise ValidationError(f"unknown setting {key!r}") + merged[key] = value + # Validate the values that affect cryptographic operations. + if merged.get("default_hash") not in key_core.HASH_ALGORITHMS: + raise ValidationError("default_hash must be sha1, sha224, sha256, sha384 or sha512") + try: + bits = int(merged.get("serial_len_bits", 64)) + except (TypeError, ValueError) as exc: + raise ValidationError("serial_len_bits must be an integer") from exc + if not (32 <= bits <= 160): + raise ValidationError("serial_len_bits must be between 32 and 160") + merged["serial_len_bits"] = bits + return merged + + +def vault_settings(vault: Vault) -> Dict[str, Any]: + merged = dict(DEFAULT_VAULT_SETTINGS) + merged.update(dict(vault.settings or {})) + return merged + + +def iso(value: Optional[datetime]) -> Optional[str]: + if value is None: + return None + if value.tzinfo is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc).isoformat() + + +def as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def parse_datetime(value: Any, field: str = "date") -> datetime: + if isinstance(value, datetime): + return as_utc(value) + if not isinstance(value, str) or not value.strip(): + raise ValidationError(f"{field} must be an ISO date-time") + text = value.strip().replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(text) + except ValueError as exc: + raise ValidationError(f"{field} must be an ISO date-time") from exc + return as_utc(parsed) + + +def parse_uuid(value: str) -> Optional[UUID]: + try: + return UUID(value) + except (ValueError, TypeError, AttributeError): + return None + + +def search_text(*parts: Any) -> str: + return " ".join(str(part) for part in parts if part).lower() + + +def extension_search_text(extensions: Iterable[Any]) -> str: + """Flatten parsed extension display text for the search index.""" + parts = [] + for row in extensions: + if isinstance(row, Mapping): + parts.append(str(row.get("name", ""))) + parts.append(str(row.get("value", ""))) + return " ".join(parts) + + +async def vault_for_owner(owner_sub: str, vault_id: Optional[UUID]) -> Optional[Vault]: + if vault_id is None: + return None + return await Vault.get_or_none(id=vault_id, owner_sub=owner_sub) + + +async def key_use_count(key_id: UUID) -> int: + certs = await Certificate.filter(key_id=key_id).count() + requests = await Request.filter(key_id=key_id).count() + return certs + requests + + +async def serialize_key_summary(key: Key) -> Dict[str, Any]: + return { + "id": str(key.id), + "name": key.name, + "comment": key.comment, + "source": key.source, + "key_type": key.key_type, + "key_size": key.key_size, + "curve": key.curve, + "has_private": key.has_private, + "fingerprint_sha256": key.public_key_sha256, + "use_count": await key_use_count(key.id), + "created_at": iso(key.created_at), + "updated_at": iso(key.updated_at), + "version": key.version, + } + + +async def serialize_key_detail(key: Key) -> Dict[str, Any]: + payload = await serialize_key_summary(key) + public_key = key_core.public_key_from_der(bytes(key.public_der)) + payload.update( + { + "public_pem": key_core.public_pem(public_key), + "public_der_hex": bytes(key.public_der).hex(), + "possible_hashes": key_core.possible_hashes(public_key), + "can_export_private": key.has_private, + } + ) + return payload + + +async def revoked_for(cert: Certificate) -> Optional[CertificateRevocation]: + if cert.issuer_id is None: + return None + return await CertificateRevocation.get_or_none(ca_id=cert.issuer_id, serial_hex=cert.serial_hex) + + +async def serialize_certificate_summary(cert: Certificate) -> Dict[str, Any]: + revoked = await revoked_for(cert) + return { + "id": str(cert.id), + "name": cert.name, + "comment": cert.comment, + "source": cert.source, + "subject": name_core.one_line(cert_core.parse_certificate(bytes(cert.cert_der)).subject), + "issuer_id": str(cert.issuer_id) if cert.issuer_id else None, + "issuer_name": name_core.one_line(cert_core.parse_certificate(bytes(cert.cert_der)).issuer), + "serial_hex": cert.serial_hex, + "not_before": iso(cert.not_before), + "not_after": iso(cert.not_after), + "is_ca": cert.is_ca, + "path_length": cert.path_length, + "has_private_key": bool(cert.key_id and await Key.filter(id=cert.key_id, has_private=True).exists()), + "revoked": revoked is not None, + "revoked_at": iso(revoked.revoked_at) if revoked else None, + "fingerprint_sha256": cert.fingerprint_sha256, + "key_id": str(cert.key_id) if cert.key_id else None, + "created_at": iso(cert.created_at), + "updated_at": iso(cert.updated_at), + "version": cert.version, + } + + +async def chain_for_certificate(cert: Certificate, vault: Vault) -> List[Certificate]: + chain = [cert] + current = cert + seen = {cert.id} + for _ in range(20): + if current.issuer_id is None or current.issuer_id in seen: + break + issuer = await Certificate.get_or_none(id=current.issuer_id, vault_id=vault.id) + if issuer is None: + break + chain.append(issuer) + seen.add(issuer.id) + current = issuer + return chain + + +async def serialize_certificate_detail(cert: Certificate, vault: Vault) -> Dict[str, Any]: + parsed = cert_core.parse_certificate(bytes(cert.cert_der)) + summary = await serialize_certificate_summary(cert) + chain = await chain_for_certificate(cert, vault) + issuer_names = [name_core.one_line(item.subject) for item in [parsed]] + chain_certs = [cert_core.parse_certificate(bytes(item.cert_der)) for item in chain] + revoked = await revoked_for(cert) + authority = await Authority.get_or_none(certificate_id=cert.id) + key = await Key.get_or_none(id=cert.key_id) if cert.key_id else None + request = await Request.get_or_none(id=cert.request_id) if cert.request_id else None + summary.update( + { + "subject_entries": cert_core.subject_entries(parsed), + "issuer_entries": cert_core.issuer_entries(parsed), + "subject_rfc4514": parsed.subject.rfc4514_string(), + "issuer_rfc4514": parsed.issuer.rfc4514_string(), + "subject_der_hex": bytes(cert.subject_der).hex(), + "issuer_der_hex": bytes(cert.issuer_der).hex(), + "signature_algorithm": cert.signature_algorithm, + "fingerprint_md5": cert.fingerprint_md5, + "fingerprint_sha1": cert.fingerprint_sha1, + "fingerprint_sha256": cert.fingerprint_sha256, + "public_key_der_hex": bytes(cert.public_key_der).hex(), + "extensions": cert_core.certificate_extensions(parsed), + "extension_spec": cert_core.certificate_extension_specs(parsed), + "openssl_config": cert_core.openssl_config(parsed), + "validation_errors": cert_core.validation_errors(parsed, chain_certs), + "purposes": cert_core.purposes(parsed), + "chain": [ + { + "id": str(item.id), + "name": item.name, + "subject": name_core.one_line(cert_core.parse_certificate(bytes(item.cert_der)).subject), + } + for item in chain + ], + "revoked": revoked is not None, + "revocation": await serialize_revocation(revoked) if revoked else None, + "key_name": key.name if key else None, + "key_type": key.key_type if key else None, + "key_size": key.key_size if key else None, + "request_id": str(request.id) if request else None, + "request_name": request.name if request else None, + "can_sign": bool(cert.is_ca and key and key.has_private), + "authority": { + "crl_expire": iso(authority.crl_expire) if authority else None, + "crl_number": authority.crl_number if authority else None, + "crl_days": authority.crl_days if authority else None, + "default_template_id": str(authority.template_id) if authority and authority.template_id else None, + "has_tls_auth_key": bool(authority and authority.tls_auth_key), + } + if authority + else None, + "cert_spec": cert_core.certificate_spec(parsed), + } + ) + return summary + + +async def serialize_revocation(row: CertificateRevocation) -> Dict[str, Any]: + return { + "id": str(row.id), + "ca_id": str(row.ca_id), + "serial_hex": row.serial_hex, + "revoked_at": iso(row.revoked_at), + "invalidity_date": iso(row.invalidity_date), + "reason": row.reason, + "crl_number": row.crl_number, + "created_at": iso(row.created_at), + } + + +async def serialize_request_summary(csr: Request) -> Dict[str, Any]: + parsed = req_core.parse_csr(bytes(csr.req_der)) + return { + "id": str(csr.id), + "name": csr.name, + "comment": csr.comment, + "source": csr.source, + "subject": name_core.one_line(parsed.subject), + "signed": csr.signed, + "signed_at": iso(csr.signed_at), + "key_id": str(csr.key_id) if csr.key_id else None, + "fingerprint_sha256": csr.request_sha256, + "certificate_count": await Certificate.filter(request_id=csr.id).count(), + "created_at": iso(csr.created_at), + "updated_at": iso(csr.updated_at), + "version": csr.version, + } + + +async def serialize_request_detail(csr: Request) -> Dict[str, Any]: + parsed = req_core.parse_csr(bytes(csr.req_der)) + payload = await serialize_request_summary(csr) + payload.update( + { + "subject_entries": [entry.as_dict() for entry in name_core.entries_from_name(parsed.subject)], + "subject_rfc4514": parsed.subject.rfc4514_string(), + "subject_der_hex": bytes(csr.subject_der).hex(), + "signature_algorithm": req_core.request_info(parsed).signature_algorithm, + "signature_valid": req_core.verify_csr(parsed), + "attributes": req_core.attribute_rows(parsed), + "extensions": req_core.csr_extensions(parsed), + "extension_spec": req_core.csr_extension_specs(parsed), + "openssl_config": req_core.openssl_config(parsed), + "public_key_der_hex": bytes(csr.public_key_der).hex(), + "csr_spec": req_core.csr_spec(parsed), + } + ) + return payload + + +async def serialize_crl_summary(crl: Crl) -> Dict[str, Any]: + return { + "id": str(crl.id), + "name": crl.name, + "comment": crl.comment, + "source": crl.source, + "issuer_id": str(crl.issuer_id) if crl.issuer_id else None, + "issuer_name": _issuer_name(crl), + "crl_number": crl.crl_number, + "last_update": iso(crl.last_update), + "next_update": iso(crl.next_update), + "revoked_count": crl.revoked_count, + "signature_algorithm": crl.signature_algorithm, + "created_at": iso(crl.created_at), + "updated_at": iso(crl.updated_at), + } + + +def _issuer_name(crl: Crl) -> str: + try: + parsed = crl_core.parse_crl(bytes(crl.crl_der)) + return parsed.issuer.rfc4514_string() + except Exception: # noqa: BLE001 + return "" + + +async def serialize_crl_detail(crl: Crl) -> Dict[str, Any]: + parsed = crl_core.parse_crl(bytes(crl.crl_der)) + payload = await serialize_crl_summary(crl) + payload.update( + { + "issuer_entries": [entry.as_dict() for entry in name_core.entries_from_name(parsed.issuer)], + "issuer_rfc4514": parsed.issuer.rfc4514_string(), + "version": "v2", + "extensions": crl_core.crl_extensions(parsed), + "revocations": crl_core.crl_revocations(parsed), + "crl_spec": crl_core.crl_spec(parsed), + } + ) + return payload + + +async def serialize_template_summary(template: Template | Mapping[str, Any]) -> Dict[str, Any]: + from kca.core.templates import Template as TemplateSpec + + if isinstance(template, TemplateSpec): + return await serialize_template_summary(template.as_dict()) + if isinstance(template, Template): + return { + "id": str(template.id), + "name": template.name, + "comment": template.comment, + "source": template.source, + "subject": template.subject, + "settings": template.settings, + "builtin": False, + "created_at": iso(template.created_at), + "updated_at": iso(template.updated_at), + "version": template.version, + } + return { + "id": str(template.get("id")), + "name": template.get("name", ""), + "comment": template.get("comment", ""), + "source": "generated", + "subject": template.get("subject", []), + "settings": template.get("settings", {}), + "builtin": True, + "created_at": None, + "updated_at": None, + "version": 1, + } + + +async def serialize_template_detail(template: Template | Mapping[str, Any]) -> Dict[str, Any]: + return await serialize_template_summary(template) + + +async def vault_counts(vault: Vault) -> Dict[str, int]: + return { + "keys": await Key.filter(vault_id=vault.id).count(), + "certificates": await Certificate.filter(vault_id=vault.id).count(), + "requests": await Request.filter(vault_id=vault.id).count(), + "crls": await Crl.filter(vault_id=vault.id).count(), + "templates": await Template.filter(vault_id=vault.id).count(), + } + + +async def serialize_vault_summary(vault: Vault) -> Dict[str, Any]: + counts = await vault_counts(vault) + now = datetime.now(timezone.utc) + expiring = await Certificate.filter( + vault_id=vault.id, not_after__gte=now, not_after__lte=now + timedelta(days=30) + ).count() + return { + "id": str(vault.id), + "name": vault.name, + "description": vault.description, + "counts": counts, + "expiring_soon": expiring, + "created_at": iso(vault.created_at), + "updated_at": iso(vault.updated_at), + "version": vault.version, + } + + +async def serialize_vault_detail(vault: Vault) -> Dict[str, Any]: + payload = await serialize_vault_summary(vault) + payload["settings"] = vault_settings(vault) + return payload + + +# --------------------------------------------------------------------------- +# Keys +# --------------------------------------------------------------------------- + + +def parse_key_generation(payload: Mapping[str, Any]) -> Dict[str, Any]: + key_type = str(payload.get("key_type") or payload.get("type") or "rsa").lower() + if key_type not in key_core.KEY_TYPES: + raise ValidationError(f"unsupported key type {key_type!r}") + size = payload.get("size", payload.get("key_size")) + if size is None: + size = key_core.DEFAULT_KEY_SIZE + try: + size = int(size) + except (TypeError, ValueError) as exc: + raise ValidationError("key size must be an integer") from exc + curve = payload.get("curve") + if key_type == "ec" and curve not in key_core.EC_CURVES: + raise ValidationError("unsupported EC curve") + name = str(payload.get("name") or "").strip() + if not name: + name = f"{key_type.upper()} {size}" if key_type != "ec" else f"EC {curve}" + comment = str(payload.get("comment") or "") + return {"key_type": key_type, "size": size, "curve": curve, "name": name, "comment": comment} + + +async def save_key( + vault: Vault, + key_obj: Any, + *, + name: str, + comment: str = "", + source: str = "generated", +) -> Key: + info = key_core.key_info(key_obj) + private_der = info.private_der + private_cipher = None + private_nonce = None + if private_der is not None: + key_id = str(secrets.token_hex(16)) + blob = encrypt_private_key(private_der, key_id, settings.master_key) + private_cipher = blob.ciphertext + private_nonce = blob.nonce + else: + key_id = str(secrets.token_hex(16)) + key = await Key.create( + vault_id=vault.id, + name=name, + comment=comment, + source=source, + key_type=info.key_type, + key_size=info.key_size, + curve=info.curve, + public_der=info.public_der, + public_key_sha256=info.public_sha256, + private_der=private_cipher, + private_nonce=private_nonce, + has_private=private_der is not None, + search_text=search_text(name, comment, info.key_type, info.key_size, info.curve, info.public_sha256), + ) + # The AAD is the row id, so re-encrypt now that the id is known. + if private_der is not None: + blob = encrypt_private_key(private_der, str(key.id), settings.master_key) + key.private_der = blob.ciphertext + key.private_nonce = blob.nonce + await key.save(update_fields=["private_der", "private_nonce"]) + return key + + +async def load_private_key(key: Key) -> Any: + if not key.has_private or key.private_der is None or key.private_nonce is None: + raise ValidationError("this key has no private part") + plaintext = decrypt_private_key( + EncryptedBlob(bytes(key.private_der), bytes(key.private_nonce)), + str(key.id), + settings.master_key, + ) + return key_core.private_key_from_der(plaintext) + + +async def key_public_object(key: Key) -> Any: + return key_core.public_key_from_der(bytes(key.public_der)) + + +def _public_part(key_obj: Any) -> Any: + """Return a public key object from either a private or public key.""" + if hasattr(key_obj, "public_key"): + return key_obj.public_key() + return key_obj + + +# --------------------------------------------------------------------------- +# Certificates +# --------------------------------------------------------------------------- + + +def parse_subject(payload: Any) -> x509.Name: + if not isinstance(payload, list): + raise ValidationError("subject must be a list of name entries") + try: + entries = name_core.entries_from_json(payload) + return name_core.name_from_entries(entries) + except ValueError as exc: + raise ValidationError(str(exc)) from exc + + +def parse_extensions(payload: Any) -> Dict[str, Any]: + if payload is None: + return ext_core.default_extension_spec() + if not isinstance(payload, dict): + raise ValidationError("extensions must be an object") + return payload + + +def validate_validity(payload: Mapping[str, Any]) -> Tuple[datetime, datetime]: + now = datetime.now(timezone.utc) + not_before = parse_datetime(payload.get("not_before") or now.isoformat(), "not_before") + raw_after = payload.get("not_after") + if raw_after: + not_after = parse_datetime(raw_after, "not_after") + else: + validity = payload.get("validity") or {} + amount = int(validity.get("amount", 365)) + unit = str(validity.get("unit", "days")) + if unit == "days": + not_after = not_before + timedelta(days=amount) + elif unit == "months": + not_after = not_before + timedelta(days=amount * 30) + elif unit == "years": + not_after = not_before + timedelta(days=amount * 365) + else: + raise ValidationError("validity unit must be days, months or years") + if validity.get("midnight"): + not_before = not_before.replace(hour=0, minute=0, second=0, microsecond=0) + not_after = not_after.replace(hour=23, minute=59, second=59, microsecond=0) + if not_after <= not_before: + raise ValidationError("not_after must be later than not_before") + return not_before, not_after + + +async def unique_serial(vault: Vault, issuer: Optional[Certificate], bits: int) -> int: + for _ in range(100): + serial = secrets.randbits(bits) + if serial <= 0: + continue + text = format(serial, "x") + if await Certificate.filter(vault_id=vault.id, serial_hex=text).exists(): + continue + if issuer is not None and await CertificateRevocation.filter(ca_id=issuer.id, serial_hex=text).exists(): + continue + return serial + raise ValidationError("failed to allocate a unique serial number") + + +async def find_certificate(vault: Vault, cert_id: Optional[str]) -> Optional[Certificate]: + uuid = parse_uuid(cert_id) if cert_id else None + if uuid is None: + return None + return await Certificate.get_or_none(id=uuid, vault_id=vault.id) + + +async def find_key(vault: Vault, key_id: Optional[str]) -> Optional[Key]: + uuid = parse_uuid(key_id) if key_id else None + if uuid is None: + return None + return await Key.get_or_none(id=uuid, vault_id=vault.id) + + +async def find_request(vault: Vault, request_id: Optional[str]) -> Optional[Request]: + uuid = parse_uuid(request_id) if request_id else None + if uuid is None: + return None + return await Request.get_or_none(id=uuid, vault_id=vault.id) + + +async def issue_certificate( + vault: Vault, + payload: Mapping[str, Any], + *, + user: User, +) -> Certificate: + """Create a certificate from the wizard payload.""" + name = str(payload.get("name") or "").strip() + if not name: + raise ValidationError("name is required") + comment = str(payload.get("comment") or "") + source = str(payload.get("source") or "generated") + request_obj = await find_request(vault, payload.get("request_id")) + if payload.get("request_id") and request_obj is None: + raise ValidationError("signing request not found") + + signer = await find_certificate(vault, payload.get("signer_id")) + if payload.get("signer_id") and signer is None: + raise ValidationError("signing certificate not found") + if signer is not None and not signer.is_ca: + raise ValidationError("the signing certificate is not a CA") + signer_key = await Key.get_or_none(id=signer.key_id) if signer and signer.key_id else None + if signer is not None and (signer_key is None or not signer_key.has_private): + raise ValidationError("the signing certificate has no private key") + + # Public/private key selection. + key_model = await find_key(vault, payload.get("key_id")) + new_key_payload = payload.get("new_key") + key_obj = None + if key_model is not None: + key_obj = await key_public_object(key_model) + if key_model.has_private and (signer is None or payload.get("use_key_private", True)): + key_obj = await load_private_key(key_model) + elif new_key_payload: + try: + generated = key_core.generate_key( + str(new_key_payload.get("key_type") or "rsa"), + int(new_key_payload.get("size") or key_core.DEFAULT_KEY_SIZE), + new_key_payload.get("curve"), + ) + except ValueError as exc: + raise ValidationError(str(exc)) from exc + key_model = await save_key( + vault, + generated, + name=str(new_key_payload.get("name") or name), + comment="Generated while creating a certificate", + source="generated", + ) + key_obj = generated + elif request_obj is not None: + key_model = await Key.get_or_none(id=request_obj.key_id) if request_obj.key_id else None + if key_model is not None and key_model.has_private: + key_obj = await load_private_key(key_model) + else: + key_obj = req_core.csr_public_key(req_core.parse_csr(bytes(request_obj.req_der))) + else: + raise ValidationError("select an existing key, generate a new key, or choose a signing request") + + # Subject and extensions. + if request_obj is not None and not payload.get("modify_subject"): + subject = req_core.parse_csr(bytes(request_obj.req_der)).subject + else: + subject = parse_subject(payload.get("subject") or []) + if not subject.rdns: + raise ValidationError("the subject must not be empty") + + extensions = parse_extensions(payload.get("extensions")) + if request_obj is not None and payload.get("copy_request_extensions"): + extensions = ext_core.extension_specs(req_core.parse_csr(bytes(request_obj.req_der)).extensions) + + not_before, not_after = validate_validity(payload) + settings_obj = vault_settings(vault) + bits = int(settings_obj.get("serial_len_bits", 64)) + raw_serial = payload.get("serial") or payload.get("serial_hex") or 0 + try: + serial = int(str(raw_serial), 16) if isinstance(raw_serial, str) else int(raw_serial) + except (TypeError, ValueError) as exc: + raise ValidationError("serial must be an integer or a hexadecimal string") from exc + if serial <= 0: + serial = await unique_serial(vault, signer, bits) + signature_hash = payload.get("signature_hash") or settings_obj.get("default_hash", "sha256") + + if signer is None: + if key_model is None or not key_model.has_private: + raise ValidationError("a self-signed certificate requires the private key") + private_key = await load_private_key(key_model) + cert = cert_core.build_self_signed( + subject=subject, + private_key=private_key, + serial=serial, + not_before=not_before, + not_after=not_after, + extension_spec=extensions, + signature_hash=signature_hash, + ) + else: + signer_private = await load_private_key(signer_key) + signer_cert = cert_core.parse_certificate(bytes(signer.cert_der)) + cert = cert_core.build_signed( + subject=subject, + public_key=_public_part(key_obj), + issuer_cert=signer_cert, + issuer_private_key=signer_private, + serial=serial, + not_before=not_before, + not_after=not_after, + extension_spec=extensions, + signature_hash=signature_hash, + ) + + info = cert_core.certificate_info(cert) + row = await Certificate.create( + vault_id=vault.id, + key_id=key_model.id if key_model else None, + issuer_id=signer.id if signer else None, + request_id=request_obj.id if request_obj else None, + name=name, + comment=comment, + source=source, + serial_hex=info.serial_hex, + subject_der=info.subject.public_bytes(), + issuer_der=info.issuer.public_bytes(), + subject_hash=info.subject_hash, + issuer_hash=info.issuer_hash, + not_before=info.not_before, + not_after=info.not_after, + is_ca=info.is_ca, + path_length=info.path_length, + cert_der=cert_core.certificate_der(cert), + public_key_der=info.public_key_der, + signature_algorithm=info.signature_algorithm, + fingerprint_md5=info.fingerprint_md5, + fingerprint_sha1=info.fingerprint_sha1, + fingerprint_sha256=info.fingerprint_sha256, + search_text=search_text( + name, + comment, + info.subject.rfc4514_string(), + info.issuer.rfc4514_string(), + info.serial_hex, + info.fingerprint_sha256, + info.signature_algorithm, + extension_search_text(cert_core.certificate_extensions(cert)), + ), + ) + if info.is_ca: + await Authority.create(certificate_id=row.id, crl_days=30) + if request_obj is not None: + request_obj.signed = True + request_obj.signed_at = datetime.now(timezone.utc) + await request_obj.save(update_fields=["signed", "signed_at"]) + await relink_certificates(vault) + return row + + +async def delete_certificate_cascade(vault: Vault, cert: Certificate) -> None: + await Certificate.filter(issuer_id=cert.id).update(issuer_id=None) + await Crl.filter(issuer_id=cert.id).update(issuer_id=None) + await CertificateRevocation.filter(ca_id=cert.id).delete() + await cert.delete() + + +async def renew_certificate( + vault: Vault, + cert: Certificate, + payload: Mapping[str, Any], +) -> Certificate: + signer = await Certificate.get_or_none(id=cert.issuer_id) if cert.issuer_id else cert + signer_key = await Key.get_or_none(id=signer.key_id) if signer and signer.key_id else None + if signer is None or signer_key is None or not signer_key.has_private: + raise ValidationError("the certificate's issuer has no private key") + parsed = cert_core.parse_certificate(bytes(cert.cert_der)) + keep_serial = bool(payload.get("keep_serial")) + serial = parsed.serial_number if keep_serial else await unique_serial(vault, signer, int(vault_settings(vault).get("serial_len_bits", 64))) + not_before, not_after = validate_validity(payload) + signer_private = await load_private_key(signer_key) + signer_cert = cert_core.parse_certificate(bytes(signer.cert_der)) + new_cert = cert_core.build_certificate( + subject=parsed.subject, + public_key=parsed.public_key(), + issuer_cert=None if signer.id == cert.id else signer_cert, + issuer_private_key=signer_private, + serial=serial, + not_before=not_before, + not_after=not_after, + extensions=list(parsed.extensions), + signature_hash=payload.get("signature_hash") or cert_core._signature_hash_name(parsed), + ) + info = cert_core.certificate_info(new_cert) + row = await Certificate.create( + vault_id=vault.id, + key_id=cert.key_id, + issuer_id=None if signer.id == cert.id else signer.id, + name=str(payload.get("name") or cert.name), + comment=str(payload.get("comment") or f"Renewed from {cert.name}"), + source="renewed", + serial_hex=info.serial_hex, + subject_der=info.subject.public_bytes(), + issuer_der=info.issuer.public_bytes(), + subject_hash=info.subject_hash, + issuer_hash=info.issuer_hash, + not_before=info.not_before, + not_after=info.not_after, + is_ca=info.is_ca, + path_length=info.path_length, + cert_der=cert_core.certificate_der(new_cert), + public_key_der=info.public_key_der, + signature_algorithm=info.signature_algorithm, + fingerprint_md5=info.fingerprint_md5, + fingerprint_sha1=info.fingerprint_sha1, + fingerprint_sha256=info.fingerprint_sha256, + search_text=search_text( + payload.get("name") or cert.name, + "renewed", + info.subject.rfc4514_string(), + info.issuer.rfc4514_string(), + info.serial_hex, + info.fingerprint_sha256, + extension_search_text(cert_core.certificate_extensions(new_cert)), + ), + ) + if info.is_ca: + await Authority.create(certificate_id=row.id, crl_days=30) + if payload.get("revoke_old") and signer.id != cert.id: + await revoke_certificate(vault, cert, {"reason": payload.get("revocation_reason", "superseded")}) + if payload.get("replace_old"): + await delete_certificate_cascade(vault, cert) + await relink_certificates(vault) + return row + + +async def revoke_certificate(vault: Vault, cert: Certificate, payload: Mapping[str, Any]) -> CertificateRevocation: + if cert.issuer_id is None: + raise ValidationError("self-signed certificates cannot be revoked") + ca = await Certificate.get_or_none(id=cert.issuer_id, vault_id=vault.id) + if ca is None: + raise ValidationError("issuer certificate not found") + key = await Key.get_or_none(id=ca.key_id) if ca.key_id else None + if key is None or not key.has_private: + raise ValidationError("the issuer has no private key") + existing = await CertificateRevocation.get_or_none(ca_id=ca.id, serial_hex=cert.serial_hex) + if existing is not None: + return existing + revoked_at = parse_datetime(payload.get("revoked_at") or datetime.now(timezone.utc).isoformat(), "revoked_at") + invalidity = payload.get("invalidity_date") + reason = str(payload.get("reason") or "unspecified") + if reason not in {item["name"] for item in crl_core.CRL_REASONS}: + raise ValidationError("unknown revocation reason") + row = await CertificateRevocation.create( + ca_id=ca.id, + serial_hex=cert.serial_hex, + revoked_at=revoked_at, + invalidity_date=parse_datetime(invalidity, "invalidity_date") if invalidity else None, + reason=reason, + ) + return row + + +async def generate_crl(vault: Vault, ca: Certificate, payload: Mapping[str, Any]) -> Crl: + if not ca.is_ca: + raise ValidationError("the selected certificate is not a CA") + key = await Key.get_or_none(id=ca.key_id) if ca.key_id else None + if key is None or not key.has_private: + raise ValidationError("the CA has no private key") + authority = await Authority.get_or_none(certificate_id=ca.id) + if authority is None: + authority = await Authority.create(certificate_id=ca.id, crl_days=30) + last_update = parse_datetime(payload.get("last_update") or datetime.now(timezone.utc).isoformat(), "last_update") + if payload.get("next_update"): + next_update = parse_datetime(payload["next_update"], "next_update") + else: + days = int(payload.get("days") or authority.crl_days or 30) + next_update = last_update + timedelta(days=days) + crl_number = int(payload.get("crl_number") or (authority.crl_number + 1)) + revocations = await CertificateRevocation.filter(ca_id=ca.id) + crl = crl_core.build_crl( + issuer_cert=cert_core.parse_certificate(bytes(ca.cert_der)), + issuer_private_key=await load_private_key(key), + revocations=[ + { + "serial_hex": row.serial_hex, + "revoked_at": row.revoked_at, + "invalidity_date": row.invalidity_date, + "reason": row.reason, + } + for row in revocations + ], + last_update=last_update, + next_update=next_update, + crl_number=crl_number, + signature_hash=payload.get("signature_hash") or vault_settings(vault).get("default_hash", "sha256"), + with_reason=bool(payload.get("with_reason", True)), + include_aki=bool(payload.get("include_aki", True)), + include_issuer_alt_name=bool(payload.get("include_issuer_alt_name", True)), + ) + info = crl_core.crl_info(crl) + row = await Crl.create( + vault_id=vault.id, + issuer_id=ca.id, + name=str(payload.get("name") or ca.name), + comment=str(payload.get("comment") or ""), + source="generated", + crl_der=crl_core.crl_der(crl), + crl_hash=info.fingerprint_sha256, + crl_number=info.crl_number, + last_update=info.last_update, + next_update=info.next_update, + revoked_count=info.revoked_count, + signature_algorithm=info.signature_algorithm, + search_text=search_text( + payload.get("name") or ca.name, + info.issuer.rfc4514_string(), + info.crl_number, + info.fingerprint_sha256, + ), + ) + authority.crl_number = max(authority.crl_number, crl_number) + authority.crl_expire = info.next_update + await authority.save(update_fields=["crl_number", "crl_expire"]) + for rev in revocations: + if not rev.crl_number: + rev.crl_number = crl_number + await rev.save(update_fields=["crl_number"]) + return row + + +# --------------------------------------------------------------------------- +# Linking imported items +# --------------------------------------------------------------------------- + + +async def relink_certificates(vault: Vault) -> None: + """Link missing key/issuer references inside one vault.""" + keys = await Key.filter(vault_id=vault.id) + key_by_public = {key.public_key_sha256: key for key in keys} + certs = await Certificate.filter(vault_id=vault.id) + for cert in certs: + changed = [] + if cert.key_id is None: + key = key_by_public.get(hashlib.sha256(bytes(cert.public_key_der)).hexdigest()) + if key is not None: + cert.key_id = key.id + changed.append("key_id") + if changed: + await cert.save(update_fields=changed) + # Reload after key links. + certs = await Certificate.filter(vault_id=vault.id) + by_hash: Dict[str, List[Certificate]] = {} + for cert in certs: + by_hash.setdefault(cert.subject_hash, []).append(cert) + for cert in certs: + if cert.issuer_id is not None: + continue + candidates = by_hash.get(cert.issuer_hash, []) + best = None + for candidate in candidates: + if candidate.id == cert.id: + continue + try: + child = cert_core.parse_certificate(bytes(cert.cert_der)) + issuer = cert_core.parse_certificate(bytes(candidate.cert_der)) + if cert_core.verify_directly_issued_by(child, issuer): + if best is None or issuer.not_valid_after_utc > cert_core.parse_certificate(bytes(best.cert_der)).not_valid_after_utc: + best = candidate + except Exception: # noqa: BLE001 + continue + if best is not None: + cert.issuer_id = best.id + await cert.save(update_fields=["issuer_id"]) + # Requests -> keys. + requests = await Request.filter(vault_id=vault.id, key_id=None) + for request in requests: + digest = hashlib.sha256(bytes(request.public_key_der)).hexdigest() + key = key_by_public.get(digest) + if key is not None: + request.key_id = key.id + await request.save(update_fields=["key_id"]) + # CRLs -> issuers. + crls = await Crl.filter(vault_id=vault.id, issuer_id=None) + certs = await Certificate.filter(vault_id=vault.id) + for crl in crls: + parsed = crl_core.parse_crl(bytes(crl.crl_der)) + for cert in certs: + try: + candidate = cert_core.parse_certificate(bytes(cert.cert_der)) + if candidate.subject == parsed.issuer and crl_core.verify_crl(parsed, candidate): + crl.issuer_id = cert.id + await crl.save(update_fields=["issuer_id"]) + break + except Exception: # noqa: BLE001 + continue + + +async def import_parsed_items( + vault: Vault, + items: Sequence[ParsedItem], + *, + selected: Optional[Sequence[int]] = None, +) -> Dict[str, Any]: + """Import parsed items, skipping duplicates. Returns a summary.""" + chosen = list(items) if selected is None else [items[index] for index in selected if 0 <= index < len(items)] + imported: List[Dict[str, Any]] = [] + skipped: List[Dict[str, Any]] = [] + # Map XCA item ids to created rows so issuer/key links can be restored. + id_map: Dict[int, Any] = {} + # Keys first, then certificates, requests, CRLs, templates. + ordered = sorted(chosen, key=lambda item: {"key": 0, "certificate": 1, "request": 2, "crl": 3, "template": 4}.get(item.kind, 9)) + for item in ordered: + try: + if item.kind == "key": + if not item.is_private: + existing = await Key.get_or_none(vault_id=vault.id, public_key_sha256=key_core.key_info(item.obj).public_sha256) + if existing is not None: + skipped.append({"kind": item.kind, "name": item.name, "reason": "duplicate"}) + continue + key = await save_key( + vault, + item.obj, + name=item.name, + comment=str(item.extra.get("comment") or ""), + source=str(item.extra.get("source") or "imported"), + ) + id_map[item.extra.get("xca_item_id")] = key + imported.append({"kind": "key", "id": str(key.id), "name": key.name}) + elif item.kind == "certificate": + info = cert_core.certificate_info(item.obj) + if await Certificate.filter(vault_id=vault.id, fingerprint_sha256=info.fingerprint_sha256).exists(): + skipped.append({"kind": item.kind, "name": item.name, "reason": "duplicate"}) + continue + key_model = None + xca_key_id = item.extra.get("xca_key_id") + if xca_key_id in id_map: + key_model = id_map[xca_key_id] + if key_model is None: + key_model = await Key.get_or_none(vault_id=vault.id, public_key_sha256=hashlib.sha256(info.public_key_der).hexdigest()) + row = await Certificate.create( + vault_id=vault.id, + key_id=key_model.id if key_model else None, + name=item.name, + comment=str(item.extra.get("comment") or ""), + source=str(item.extra.get("source") or "imported"), + serial_hex=info.serial_hex, + subject_der=info.subject.public_bytes(), + issuer_der=info.issuer.public_bytes(), + subject_hash=info.subject_hash, + issuer_hash=info.issuer_hash, + not_before=info.not_before, + not_after=info.not_after, + is_ca=info.is_ca, + path_length=info.path_length, + cert_der=cert_core.certificate_der(item.obj), + public_key_der=info.public_key_der, + signature_algorithm=info.signature_algorithm, + fingerprint_md5=info.fingerprint_md5, + fingerprint_sha1=info.fingerprint_sha1, + fingerprint_sha256=info.fingerprint_sha256, + search_text=search_text( + item.name, + info.subject.rfc4514_string(), + info.issuer.rfc4514_string(), + info.serial_hex, + info.fingerprint_sha256, + extension_search_text(cert_core.certificate_extensions(item.obj)), + ), + ) + id_map[item.extra.get("xca_item_id")] = row + if info.is_ca: + authority_extra = item.extra.get("authority") or {} + authority = await Authority.create( + certificate_id=row.id, + crl_expire=authority_extra.get("crl_expire"), + crl_number=int(authority_extra.get("crl_number") or 0), + crl_days=int(authority_extra.get("crl_days") or 30), + ) + takey = item.extra.get("tls_auth_key") + if isinstance(takey, (bytes, bytearray)) and takey: + blob = encrypt_blob(bytes(takey), master_key=settings.master_key, aad=str(row.id).encode()) + authority.tls_auth_key = blob.ciphertext + authority.tls_auth_key_nonce = blob.nonce + await authority.save(update_fields=["tls_auth_key", "tls_auth_key_nonce"]) + imported.append({"kind": "certificate", "id": str(row.id), "name": row.name}) + elif item.kind == "request": + info = req_core.request_info(item.obj) + if await Request.filter(vault_id=vault.id, request_sha256=info.fingerprint_sha256).exists(): + skipped.append({"kind": item.kind, "name": item.name, "reason": "duplicate"}) + continue + key_model = id_map.get(item.extra.get("xca_key_id")) + if key_model is None: + key_model = await Key.get_or_none(vault_id=vault.id, public_key_sha256=hashlib.sha256(info.public_key_der).hexdigest()) + row = await Request.create( + vault_id=vault.id, + key_id=key_model.id if key_model else None, + name=item.name, + comment=str(item.extra.get("comment") or ""), + source=str(item.extra.get("source") or "imported"), + subject_der=info.subject.public_bytes(), + req_der=req_core.csr_der(item.obj), + public_key_der=info.public_key_der, + request_sha256=info.fingerprint_sha256, + signed=bool(item.extra.get("signed")), + signed_at=datetime.now(timezone.utc) if item.extra.get("signed") else None, + attributes=info.attributes, + search_text=search_text( + item.name, + info.subject.rfc4514_string(), + info.fingerprint_sha256, + extension_search_text(req_core.csr_extensions(item.obj)), + ), + ) + id_map[item.extra.get("xca_item_id")] = row + imported.append({"kind": "request", "id": str(row.id), "name": row.name}) + elif item.kind == "crl": + info = crl_core.crl_info(item.obj) + if await Crl.filter(vault_id=vault.id, crl_hash=info.fingerprint_sha256).exists(): + skipped.append({"kind": item.kind, "name": item.name, "reason": "duplicate"}) + continue + row = await Crl.create( + vault_id=vault.id, + name=item.name, + comment=str(item.extra.get("comment") or ""), + source=str(item.extra.get("source") or "imported"), + crl_der=crl_core.crl_der(item.obj), + crl_hash=info.fingerprint_sha256, + crl_number=info.crl_number, + last_update=info.last_update, + next_update=info.next_update, + revoked_count=info.revoked_count, + signature_algorithm=info.signature_algorithm, + search_text=search_text(item.name, info.issuer.rfc4514_string(), info.crl_number), + ) + id_map[item.extra.get("xca_item_id")] = row + imported.append({"kind": "crl", "id": str(row.id), "name": row.name}) + elif item.kind == "template": + template = item.obj + row = await Template.create( + vault_id=vault.id, + name=str(template.get("name") or item.name), + comment=str(template.get("comment") or ""), + source="imported", + subject=template.get("subject") or [], + settings=template.get("settings") or {}, + search_text=search_text(template.get("name"), template.get("comment")), + ) + id_map[item.extra.get("xca_item_id")] = row + imported.append({"kind": "template", "id": str(row.id), "name": row.name}) + except Exception as exc: # noqa: BLE001 + skipped.append({"kind": item.kind, "name": item.name, "reason": str(exc)}) + # Restore XCA issuer links after all certificates exist. + for item in ordered: + if item.kind != "certificate": + continue + row = id_map.get(item.extra.get("xca_item_id")) + issuer_id = item.extra.get("xca_issuer_id") + if row is not None and issuer_id in id_map: + issuer = id_map[issuer_id] + if isinstance(issuer, Certificate): + row.issuer_id = issuer.id + await row.save(update_fields=["issuer_id"]) + # Imported revocations onto the CA. + if row is not None and item.extra.get("revocations"): + for rev in item.extra["revocations"]: + if rev.get("revoked_at") is None: + continue + await CertificateRevocation.get_or_create( + ca_id=row.id, + serial_hex=str(rev.get("serial_hex") or ""), + defaults={ + "revoked_at": rev["revoked_at"], + "invalidity_date": rev.get("invalidity_date"), + "reason": _reason_from_bit(int(rev.get("reason_bit") or 0)), + "crl_number": int(rev.get("crl_number") or 0), + }, + ) + await relink_certificates(vault) + # Merge the revocations carried by imported CRLs into their CA, as XCA + # does when a CRL is imported. + for crl in await Crl.filter(vault_id=vault.id, issuer_id__not=None): + ca = await Certificate.get_or_none(id=crl.issuer_id, vault_id=vault.id) + if ca is None: + continue + parsed_crl = crl_core.parse_crl(bytes(crl.crl_der)) + for rev in crl_core.crl_revocations(parsed_crl): + if not rev.get("revoked_at"): + continue + await CertificateRevocation.get_or_create( + ca_id=ca.id, + serial_hex=str(rev["serial_hex"]), + defaults={ + "revoked_at": parse_datetime(rev["revoked_at"]), + "invalidity_date": parse_datetime(rev["invalidity_date"]) if rev.get("invalidity_date") else None, + "reason": rev.get("reason") or "unspecified", + "crl_number": int(crl.crl_number or 0), + }, + ) + return {"imported": imported, "skipped": skipped} + + +def _reason_from_bit(bit: int) -> str: + mapping = { + 0: "unspecified", + 1: "keyCompromise", + 2: "CACompromise", + 3: "affiliationChanged", + 4: "superseded", + 5: "cessationOfOperation", + 6: "certificateHold", + 7: "removeFromCRL", + 8: "privilegeWithdrawn", + 9: "AACompromise", + } + return mapping.get(bit, "unspecified") + + +__all__ = [ + "DEFAULT_VAULT_SETTINGS", + "ValidationError", + "chain_for_certificate", + "extension_search_text", + "delete_certificate_cascade", + "find_certificate", + "find_key", + "find_request", + "generate_crl", + "import_parsed_items", + "iso", + "issue_certificate", + "key_public_object", + "load_private_key", + "merge_settings", + "parse_datetime", + "parse_key_generation", + "parse_subject", + "parse_uuid", + "relink_certificates", + "renew_certificate", + "revoke_certificate", + "save_key", + "serialize_certificate_detail", + "serialize_certificate_summary", + "serialize_crl_detail", + "serialize_crl_summary", + "serialize_key_detail", + "serialize_key_summary", + "serialize_request_detail", + "serialize_request_summary", + "serialize_revocation", + "serialize_template_detail", + "serialize_template_summary", + "serialize_vault_detail", + "serialize_vault_summary", + "unique_serial", + "vault_for_owner", + "vault_settings", +] diff --git a/server/packages/kca-app/src/kca/static.py b/server/packages/kca-app/src/kca/static.py new file mode 100644 index 0000000..4bc3f6e --- /dev/null +++ b/server/packages/kca-app/src/kca/static.py @@ -0,0 +1,70 @@ +"""SPA shell hosting for the compiled single-page application.""" +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Mapping + +from kaya.core import HttpContext +from kaya.openapi import operation + +from .app import app +from .config import settings + +_ICON_CONTENT_TYPES: Mapping[str, str] = { + "apple-touch-icon.png": "image/png", + "icon-192.png": "image/png", + "icon-512.png": "image/png", + "icon-maskable-512.png": "image/png", + "icon.svg": "image/svg+xml", +} + + +async def _send_file(ctx: HttpContext, name: str, content_type: str, cache_control: str) -> None: + path = Path(settings.static_dir) / name + if not path.is_file(): + await ctx.send_empty(404) + return + body = await asyncio.to_thread(path.read_bytes) + await ctx.send_bytes(200, body, {"content-type": (content_type,), "cache-control": (cache_control,)}) + + +async def _send_shell(ctx: HttpContext) -> None: + index = Path(settings.static_dir) / "index.html" + if not index.is_file(): + await ctx.send_empty(404) + return + body = await asyncio.to_thread(index.read_bytes) + await ctx.send_bytes(200, body, {"content-type": ("text/html; charset=utf-8",)}) + + +@app.GET("/sw.js") +@operation(hidden=True) +async def service_worker(ctx: HttpContext) -> None: + await _send_file(ctx, "sw.js", "text/javascript; charset=utf-8", "no-cache") + + +@app.GET("/manifest.webmanifest") +@operation(hidden=True) +async def web_manifest(ctx: HttpContext) -> None: + await _send_file(ctx, "manifest.webmanifest", "application/manifest+json", "no-cache") + + +@app.GET("/icons/${name}") +@operation(hidden=True) +async def icon(ctx: HttpContext, name: str) -> None: + content_type = _ICON_CONTENT_TYPES.get(name) + if content_type is None: + await ctx.send_empty(404) + return + await _send_file(ctx, f"icons/{name}", content_type, "public, max-age=86400") + + +@app.GET("/") +async def index(ctx: HttpContext) -> None: + await _send_shell(ctx) + + +@app.GET("/*", recursive=True) +async def spa(ctx: HttpContext, _matched: object = None) -> None: + await _send_shell(ctx) diff --git a/server/packages/kca-app/src/kca/tokens.py b/server/packages/kca-app/src/kca/tokens.py new file mode 100644 index 0000000..20a83c0 --- /dev/null +++ b/server/packages/kca-app/src/kca/tokens.py @@ -0,0 +1,107 @@ +"""Personal access tokens: generation, hashing and verification.""" +from __future__ import annotations + +import hashlib +import secrets +from datetime import date, datetime, timedelta, timezone +from typing import Any, Dict, Mapping, Optional, Tuple + +from kaya.core import HttpContext + +from .models import ApiToken, User + +TOKEN_PREFIX = "kca_" +PREFIX_LENGTH = 12 +TOKEN_ENTROPY_BYTES = 32 +MAX_TOKENS_PER_USER = 50 +LAST_USED_REFRESH = timedelta(minutes=1) +MAX_TOKEN_NAME = 100 + + +class TokenValidationError(ValueError): + pass + + +def generate_token() -> Tuple[str, str, str]: + secret = TOKEN_PREFIX + secrets.token_urlsafe(TOKEN_ENTROPY_BYTES) + return secret, secret[:PREFIX_LENGTH], hash_token(secret) + + +def hash_token(secret: str) -> str: + return hashlib.sha256(secret.encode("utf-8")).hexdigest() + + +def bearer_token(ctx: HttpContext) -> Optional[str]: + values = ctx.headers.get("authorization") + if not values: + return None + parts = values[0].split(None, 1) + if len(parts) != 2 or parts[0].lower() != "bearer": + return None + credential = parts[1].strip() + return credential or None + + +async def resolve_token(secret: str) -> Optional[User]: + token = await ApiToken.get_or_none(token_hash=hash_token(secret)) + if token is None: + return None + if token.expires_at is not None and token.expires_at < date.today(): + return None + now = datetime.now(timezone.utc) + last_used = token.last_used_at + if last_used is not None and last_used.tzinfo is None: + last_used = last_used.replace(tzinfo=timezone.utc) + if last_used is None or now - last_used >= LAST_USED_REFRESH: + token.last_used_at = now + await token.save(update_fields=["last_used_at"]) + return await User.get_or_none(sub=token.user_sub) + + +def parse_token_payload(payload: Mapping[str, Any]) -> Dict[str, Any]: + name = payload.get("name") + if not isinstance(name, str) or not name.strip(): + raise TokenValidationError("name is required") + name = name.strip() + if len(name) > MAX_TOKEN_NAME: + raise TokenValidationError(f"name must be at most {MAX_TOKEN_NAME} characters") + expires_at: Optional[date] = None + raw_expiry = payload.get("expires_at") + if raw_expiry is not None: + if not isinstance(raw_expiry, str) or not raw_expiry: + raise TokenValidationError("expires_at must be an ISO date (YYYY-MM-DD) or null") + try: + expires_at = date.fromisoformat(raw_expiry) + except ValueError as exc: + raise TokenValidationError("expires_at must be an ISO date (YYYY-MM-DD) or null") from exc + if expires_at < date.today(): + raise TokenValidationError("expires_at must not be in the past") + return {"name": name, "expires_at": expires_at} + + +def serialize_token(token: ApiToken) -> Dict[str, Any]: + return { + "id": str(token.id), + "name": token.name, + "prefix": token.token_prefix, + "created_at": token.created_at.isoformat(), + "last_used_at": token.last_used_at.isoformat() if token.last_used_at else None, + "expires_at": token.expires_at.isoformat() if token.expires_at else None, + } + + +__all__ = [ + "LAST_USED_REFRESH", + "MAX_TOKEN_NAME", + "MAX_TOKENS_PER_USER", + "PREFIX_LENGTH", + "TOKEN_ENTROPY_BYTES", + "TOKEN_PREFIX", + "TokenValidationError", + "bearer_token", + "generate_token", + "hash_token", + "parse_token_payload", + "resolve_token", + "serialize_token", +] diff --git a/server/packages/kca-app/src/kca/tortoise_mixin.py b/server/packages/kca-app/src/kca/tortoise_mixin.py new file mode 100644 index 0000000..0f49321 --- /dev/null +++ b/server/packages/kca-app/src/kca/tortoise_mixin.py @@ -0,0 +1,145 @@ +"""A :class:`~kaya.core.KayaMixin` that drives the TortoiseORM lifecycle. + +kaya calls ``KayaMixin.setup`` / ``shutdown`` synchronously from inside a +running event loop. Tortoise 1.x binds database connections to a +:class:`~tortoise.context.TortoiseContext` looked up via a contextvar, and +kaya dispatches each HTTP request as a separate ``loop.create_task``, so a +context set by an early request does not automatically reach later +requests. + +This mixin therefore: + +1. Lazily builds a :class:`TortoiseContext` for the active event loop + (rebuilding it if the running loop changes, which happens in tests that + use a fresh ``asyncio.run`` per test). +2. Per HTTP request, binds that context to the current task via the + ``_current_context`` contextvar so the handler — running in the same + task as the ``before_request`` hook — sees an active context. +3. Exposes :meth:`ensure_context` for background work that belongs to no + request (used by the test suite and any future worker). + +It deliberately avoids the global-fallback singleton +(``_enable_global_fallback``), which Tortoise only allows to be set once +per process and would therefore break across event loops. + +Schema management is split by backend: in-memory sqlite databases (the +test suite) get ``generate_schemas`` on every fresh context; Postgres +schemas are owned by aerich migrations and must be applied externally +(``aerich upgrade``, run by the ``db-migrate`` compose service) before +the app serves requests. +""" +from __future__ import annotations + +from asyncio import AbstractEventLoop, get_running_loop +from logging import getLogger +from typing import AbstractSet, Optional, Sequence +from urllib.parse import urlsplit, urlunsplit + +from kaya.core import HttpContext, KayaApp, KayaMixin +from tortoise.context import TortoiseContext, _current_context + +log = getLogger(__name__) + + +def _safe_url(database_url: str) -> str: + """The database URL with any credentials stripped, for logging.""" + parts = urlsplit(database_url) + host = parts.hostname or "" + try: + if parts.port: + host = f"{host}:{parts.port}" + except ValueError: + # Non-numeric netloc (e.g. sqlite://:memory:): keep the host only. + pass + return urlunsplit((parts.scheme, host, parts.path, "", "")) + + +class TortoiseMixin(KayaMixin): + """Initialize and tear down a per-loop :class:`TortoiseContext`.""" + + def __init__(self, + database_url: str, + models_modules: Sequence[str], + skip_paths: AbstractSet[str] = frozenset({"/api/health"})) -> None: + self._database_url = database_url + self._models_modules = list(models_modules) + self._skip_paths = skip_paths + self._ctx: "Optional[TortoiseContext]" = None + self._init_loop: "Optional[AbstractEventLoop]" = None + + def apply(self, app: KayaApp) -> None: + app.add_before_request_hook(self._ensure_context) + + def setup(self, loop: AbstractEventLoop) -> None: + pass + + def shutdown(self, loop: AbstractEventLoop) -> None: + if self._init_loop is loop and self._ctx is not None: + log.info("closing database connections") + loop.create_task(self._ctx.close_connections()) + self._ctx = None + self._init_loop = None + + async def aclose(self) -> None: + """Close the current context's connections and forget it. + + Must be called from the event loop that owns the context. When a + loop goes away without this, its aiosqlite connections are orphaned; + their non-daemon worker threads then block interpreter shutdown + forever. The test suite calls this at the end of every test because + each test runs in a fresh event loop. + """ + ctx = self._ctx + self._ctx = None + self._init_loop = None + if ctx is not None: + log.info("closing database connections") + await ctx.close_connections() + + async def _build_context(self) -> TortoiseContext: + ctx = TortoiseContext() + with ctx: + await ctx.init( + db_url=self._database_url, + modules={"models": self._models_modules}, + ) + log.info("database context initialized (%s)", _safe_url(self._database_url)) + # Schema creation is only done for sqlite (in-memory test + # databases). Postgres schemas are managed by aerich migrations + # (applied by the db-migrate compose service / `aerich upgrade`). + if self._database_url.startswith("sqlite"): + await ctx.generate_schemas() + log.info("sqlite schemas generated") + return ctx + + async def ensure_context(self) -> None: + """Bind this mixin's context to the calling task. + + Background work with no request of its own calls this before + touching the database. It is cheap when the context is already + built for the running loop and idempotent for callers that already + have it bound (HTTP requests). + """ + await self._bind() + + async def _bind(self) -> None: + loop = get_running_loop() + if self._init_loop is not loop: + if self._ctx is not None: + # A previous test loop went away; drop its context. + self._ctx = None + log.debug("building a Tortoise context for a new event loop") + self._ctx = await self._build_context() + self._init_loop = loop + assert self._ctx is not None + _current_context.set(self._ctx) + + async def _ensure_context(self, ctx: HttpContext): + if ctx.path in self._skip_paths: + return None + # Only API endpoints touch the database: auth callbacks and the + # static SPA catch-all must not pay for a Tortoise context. + if not ctx.path.startswith("/api/"): + return None + await self._bind() + return None diff --git a/server/packages/kca-core/README.md b/server/packages/kca-core/README.md new file mode 100644 index 0000000..e519794 --- /dev/null +++ b/server/packages/kca-core/README.md @@ -0,0 +1,7 @@ +# kca-core + +Pure, I/O-free PKI domain logic for KCA. It knows how to generate and parse +keys, build distinguished names, encode X.509 extensions, issue certificates, +create CSRs and CRLs, apply templates, and convert every supported import and +export format. It has no kaya, database or filesystem dependencies and is +unit-tested in isolation. diff --git a/server/packages/kca-core/pyproject.toml b/server/packages/kca-core/pyproject.toml new file mode 100644 index 0000000..550257c --- /dev/null +++ b/server/packages/kca-core/pyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "kca-core" +version = "0.1.0" +description = "Pure PKI domain logic for KCA (keys, names, extensions, certificates, CSRs, CRLs, templates, imports/exports)" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "cryptography>=44", + "paramiko>=3.5", +] + +[tool.setuptools.packages.find] +where = ["src"] +namespaces = true diff --git a/server/packages/kca-core/src/kca/core/__init__.py b/server/packages/kca-core/src/kca/core/__init__.py new file mode 100644 index 0000000..ed12465 --- /dev/null +++ b/server/packages/kca-core/src/kca/core/__init__.py @@ -0,0 +1 @@ +"""Pure PKI domain logic for KCA.""" diff --git a/server/packages/kca-core/src/kca/core/certificates.py b/server/packages/kca-core/src/kca/core/certificates.py new file mode 100644 index 0000000..1b31957 --- /dev/null +++ b/server/packages/kca-core/src/kca/core/certificates.py @@ -0,0 +1,522 @@ +"""Certificate construction, parsing, validation and export helpers.""" +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed25519, rsa +from cryptography.x509.oid import ExtensionOID, NameOID + +from .extensions import ( + build_extensions, + eku_name, + extension_config_lines, + extension_rows, + extension_specs, +) +from .keys import PrivateKey, PublicKey, hash_algorithm, public_der +from .names import entries_from_name, name_from_entries, name_hash, one_line + +#: XCA's "no well-defined expiration" sentinel. +FAR_FUTURE = datetime(9999, 12, 31, 23, 59, 59, tzinfo=timezone.utc) + +MAX_SERIAL_BYTES = 20 + + +@dataclass(frozen=True) +class CertificateInfo: + subject: x509.Name + issuer: x509.Name + serial_hex: str + not_before: datetime + not_after: datetime + is_ca: bool + path_length: Optional[int] + signature_algorithm: str + public_key_der: bytes + fingerprint_md5: str + fingerprint_sha1: str + fingerprint_sha256: str + subject_hash: str + issuer_hash: str + + +def _as_utc(value: datetime) -> datetime: + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def normalize_datetime(value: datetime) -> datetime: + return _as_utc(value) + + +def serial_to_hex(value: int) -> str: + return format(value, "x") + + +def parse_serial_hex(value: str) -> int: + text = (value or "").strip().lower().replace(":", "") + if text.startswith("0x"): + text = text[2:] + if not text: + raise ValueError("serial number is empty") + if len(text) > MAX_SERIAL_BYTES * 2: + raise ValueError("serial number must be at most 20 octets") + serial = int(text, 16) + if serial <= 0: + raise ValueError("serial number must be positive") + return serial + + +def build_certificate( + *, + subject: x509.Name, + public_key: PublicKey, + issuer_cert: Optional[x509.Certificate], + issuer_private_key: PrivateKey, + serial: int, + not_before: datetime, + not_after: datetime, + extensions: Iterable[x509.Extension], + signature_hash: Optional[str] = "sha256", +) -> x509.Certificate: + """Issue a certificate (self-signed when ``issuer_cert`` is None).""" + if serial <= 0: + raise ValueError("serial number must be positive") + if serial.bit_length() > MAX_SERIAL_BYTES * 8: + raise ValueError("serial number is too large") + not_before = normalize_datetime(not_before) + not_after = normalize_datetime(not_after) + if not_after <= not_before: + raise ValueError("notAfter must be later than notBefore") + issuer_name = issuer_cert.subject if issuer_cert is not None else subject + builder = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer_name) + .public_key(public_key) + .serial_number(serial) + .not_valid_before(not_before) + .not_valid_after(not_after) + ) + for extension in extensions: + builder = builder.add_extension(extension.value, extension.critical) + algorithm = hash_algorithm(signature_hash, issuer_private_key) + return builder.sign(private_key=issuer_private_key, algorithm=algorithm) + + +def parse_certificate(data: bytes) -> x509.Certificate: + return x509.load_der_x509_certificate(data) + + +def certificate_der(cert: x509.Certificate) -> bytes: + return cert.public_bytes(serialization.Encoding.DER) + + +def certificate_pem(cert: x509.Certificate) -> str: + return cert.public_bytes(serialization.Encoding.PEM).decode("ascii") + + +def certificate_info(cert: x509.Certificate) -> CertificateInfo: + try: + bc = cert.extensions.get_extension_for_class(x509.BasicConstraints).value + is_ca = bool(bc.ca) + path_length = bc.path_length + except x509.ExtensionNotFound: + is_ca = False + path_length = None + return CertificateInfo( + subject=cert.subject, + issuer=cert.issuer, + serial_hex=serial_to_hex(cert.serial_number), + not_before=normalize_datetime(cert.not_valid_before_utc), + not_after=normalize_datetime(cert.not_valid_after_utc), + is_ca=is_ca, + path_length=path_length, + signature_algorithm=signature_algorithm_name(cert), + public_key_der=cert.public_key().public_bytes( + serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo + ), + fingerprint_md5=fingerprint(cert, "md5"), + fingerprint_sha1=fingerprint(cert, "sha1"), + fingerprint_sha256=fingerprint(cert, "sha256"), + subject_hash=name_hash(cert.subject), + issuer_hash=name_hash(cert.issuer), + ) + + +_SIGNATURE_OIDS = { + "1.2.840.113549.1.1.5": "sha1WithRSAEncryption", + "1.2.840.113549.1.1.11": "sha256WithRSAEncryption", + "1.2.840.113549.1.1.12": "sha384WithRSAEncryption", + "1.2.840.113549.1.1.13": "sha512WithRSAEncryption", + "1.2.840.113549.1.1.14": "sha224WithRSAEncryption", + "1.2.840.10045.4.1": "ecdsa-with-SHA1", + "1.2.840.10045.4.3.2": "ecdsa-with-SHA256", + "1.2.840.10045.4.3.3": "ecdsa-with-SHA384", + "1.2.840.10045.4.3.4": "ecdsa-with-SHA512", + "1.3.101.112": "Ed25519", +} + + +def signature_algorithm_name(cert: x509.Certificate) -> str: + algorithm = cert.signature_algorithm_oid + dotted = algorithm.dotted_string + if dotted in _SIGNATURE_OIDS: + return _SIGNATURE_OIDS[dotted] + name = getattr(algorithm, "name", None) + if name and name != "Undefined": + return name + return dotted + + +def fingerprint(cert: x509.Certificate, algorithm: str = "sha256") -> str: + data = certificate_der(cert) + try: + digest = hashlib.new(algorithm.lower()) + except ValueError as exc: + raise ValueError(f"unsupported fingerprint algorithm {algorithm!r}") from exc + digest.update(data) + return digest.hexdigest() + + +def is_self_signed(cert: x509.Certificate) -> bool: + if cert.subject != cert.issuer: + return False + try: + cert.verify_directly_issued_by(cert) + return True + except Exception: # noqa: BLE001 - invalid signature + return False + + +def verify_directly_issued_by(cert: x509.Certificate, issuer: x509.Certificate) -> bool: + try: + cert.verify_directly_issued_by(issuer) + return True + except Exception: # noqa: BLE001 + return False + + +def find_issuer(cert: x509.Certificate, candidates: Iterable[x509.Certificate]) -> Optional[x509.Certificate]: + """Find the best issuer among ``candidates`` (latest expiry wins).""" + if is_self_signed(cert): + return cert + matches = [ + candidate + for candidate in candidates + if candidate is not cert + and candidate.subject == cert.issuer + and verify_directly_issued_by(cert, candidate) + ] + if not matches: + return None + return max(matches, key=lambda candidate: candidate.not_valid_after_utc) + + +def chain_for( + cert: x509.Certificate, + lookup: Dict[str, x509.Certificate], + max_depth: int = 20, +) -> List[x509.Certificate]: + """Follow issuer links in ``lookup`` (issuer_hash -> cert) up to a root.""" + chain = [cert] + current = cert + seen = {cert.fingerprint(hashes.SHA256())} + for _ in range(max_depth): + if is_self_signed(current): + break + issuer = lookup.get(name_hash(current.issuer)) + if issuer is None or issuer.fingerprint(hashes.SHA256()) in seen: + break + if not verify_directly_issued_by(current, issuer): + break + chain.append(issuer) + seen.add(issuer.fingerprint(hashes.SHA256())) + current = issuer + return chain + + +def validation_errors(cert: x509.Certificate, chain: Sequence[x509.Certificate]) -> List[Dict[str, Any]]: + """OpenSSL-backed chain validation, returning error code/message pairs. + + XCA validates end-entity certificates with the X.509 strict and policy + flags and lists every error. pyOpenSSL surfaces one error per context, so + we verify the chain once and, for missing issuers, report that explicitly. + """ + errors: List[Dict[str, Any]] = [] + if is_self_signed(cert): + return errors + if len(chain) < 2: + return [{"code": 20, "message": "unable to get local issuer certificate"}] + try: + from OpenSSL import crypto + + store = crypto.X509Store() + # The root of the supplied chain is trusted; intermediates are + # untrusted inputs, exactly like XCA's trusted/untrusted stacks. + for item in chain[1:]: + converted = crypto.X509.from_cryptography(item) + if is_self_signed(item): + store.add_cert(converted) + else: + store.add_cert(converted) + context = crypto.X509StoreContext( + store, + crypto.X509.from_cryptography(cert), + [crypto.X509.from_cryptography(item) for item in chain[1:]], + ) + context.verify_certificate() + except Exception as exc: # noqa: BLE001 - pyOpenSSL raises rich errors + code = getattr(exc, "args", [None])[-1] if getattr(exc, "args", None) else None + if not isinstance(code, int): + code = None + errors.append({"code": code, "message": str(exc)}) + return errors + + +def purposes(cert: x509.Certificate) -> List[str]: + """A practical purpose list derived from EKU/KU (XCA uses OpenSSL's checks).""" + result: List[str] = [] + try: + eku = cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value + oids = {oid.dotted_string for oid in eku} + except x509.ExtensionNotFound: + oids = set() + labels = { + "1.3.6.1.5.5.7.3.1": "SSL server", + "1.3.6.1.5.5.7.3.2": "SSL client", + "1.3.6.1.5.5.7.3.3": "Code signing", + "1.3.6.1.5.5.7.3.4": "E-mail protection", + "1.3.6.1.5.5.7.3.8": "Time stamping", + "1.3.6.1.5.5.7.3.9": "OCSP helper", + } + for oid, label in labels.items(): + if oid in oids: + result.append(label) + if not oids: + result.append("Any purpose") + return result + + +def certificate_spec(cert: x509.Certificate) -> Dict[str, Any]: + """A certificate-creation spec for "similar certificate" / renewal.""" + info = certificate_info(cert) + return { + "subject": [entry.as_dict() for entry in entries_from_name(cert.subject)], + "serial_hex": info.serial_hex, + "not_before": info.not_before.isoformat(), + "not_after": info.not_after.isoformat(), + "signature_hash": _signature_hash_name(cert), + "extensions": extension_specs(cert.extensions), + "public_key_der": info.public_key_der.hex(), + } + + +def signature_hash_name(cert: x509.Certificate) -> Optional[str]: + try: + algorithm = cert.signature_hash_algorithm + except Exception: # noqa: BLE001 - Ed25519 + return None + if algorithm is None: + return None + return algorithm.name + + +# Backwards-compatible private alias used by the app layer. +_signature_hash_name = signature_hash_name + + +def index_entry(cert: x509.Certificate, *, revoked_at: Optional[datetime] = None) -> str: + """One line in OpenSSL's ``index.txt`` format.""" + now = datetime.now(timezone.utc) + if revoked_at is not None: + flag = "R" + elif cert.not_valid_before_utc <= now <= cert.not_valid_after_utc: + flag = "V" + else: + flag = "E" + expiry = cert.not_valid_after_utc.strftime("%Y%m%d%H%M%SZ") + revoked = revoked_at.astimezone(timezone.utc).strftime("%Y%m%d%H%M%SZ") if revoked_at else "" + subject = cert.subject.rfc4514_string() + return f"{flag}\t{expiry}\t{revoked}\t{serial_to_hex(cert.serial_number)}\tunknown\t{subject}\n" + + +def openssl_config(cert: x509.Certificate) -> str: + """An OpenSSL configuration fragment reproducing the certificate.""" + lines = [ + "[req]", + "distinguished_name = req_distinguished_name", + "prompt = no", + "", + "[req_distinguished_name]", + ] + for entry in entries_from_name(cert.subject): + lines.append(f"{entry.name} = {entry.value}") + lines.extend(["", "[v3_ext]"]) + config = extension_config_lines(cert.extensions) + if config: + lines.append(config) + return "\n".join(lines) + "\n" + + +def ics_event(cert: x509.Certificate, name: str = "") -> str: + """A vCalendar reminder for the certificate's expiry.""" + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + start = cert.not_valid_after_utc.strftime("%Y%m%dT%H%M%SZ") + summary = f"Renew certificate: {name or cert.subject.rfc4514_string()}" + description = ( + f"The certificate '{name or cert.subject.rfc4514_string()}' " + f"will expire on {cert.not_valid_after_utc.isoformat()}." + ) + return "\r\n".join( + [ + "BEGIN:VEVENT", + f"UID:{fingerprint(cert, 'sha256')}@kca", + f"DTSTAMP:{stamp}", + f"DTSTART:{start}", + f"SUMMARY:{_ics_escape(summary)}", + f"DESCRIPTION:{_ics_escape(description)}", + "END:VEVENT", + ] + ) + + +def _ics_escape(value: str) -> str: + return value.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n") + + +def ics_calendar(events: Sequence[str]) -> str: + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//KCA//XCA Web//EN", + "CALSCALE:GREGORIAN", + ] + for event in events: + lines.extend(event.split("\r\n")) + lines.append("END:VCALENDAR") + return "\r\n".join(lines) + "\r\n" + + +def certificate_public_key(cert: x509.Certificate) -> PublicKey: + return cert.public_key() + + +def subject_entries(cert: x509.Certificate) -> List[Dict[str, str]]: + return [entry.as_dict() for entry in entries_from_name(cert.subject)] + + +def issuer_entries(cert: x509.Certificate) -> List[Dict[str, str]]: + return [entry.as_dict() for entry in entries_from_name(cert.issuer)] + + +def certificate_extensions(cert: x509.Certificate) -> List[Dict[str, Any]]: + return extension_rows(cert.extensions) + + +def certificate_extension_specs(cert: x509.Certificate) -> Dict[str, Any]: + return extension_specs(cert.extensions) + + +def build_self_signed( + *, + subject: x509.Name, + private_key: PrivateKey, + serial: int, + not_before: datetime, + not_after: datetime, + extension_spec: Optional[Dict[str, Any]] = None, + signature_hash: Optional[str] = "sha256", +) -> x509.Certificate: + public_key = private_key.public_key() + extensions = build_extensions( + extension_spec, + public_key=public_key, + subject_name=subject, + issuer_cert=None, + ) + return build_certificate( + subject=subject, + public_key=public_key, + issuer_cert=None, + issuer_private_key=private_key, + serial=serial, + not_before=not_before, + not_after=not_after, + extensions=extensions, + signature_hash=signature_hash, + ) + + +def build_signed( + *, + subject: x509.Name, + public_key: PublicKey, + issuer_cert: x509.Certificate, + issuer_private_key: PrivateKey, + serial: int, + not_before: datetime, + not_after: datetime, + extension_spec: Optional[Dict[str, Any]] = None, + signature_hash: Optional[str] = "sha256", +) -> x509.Certificate: + extensions = build_extensions( + extension_spec, + public_key=public_key, + subject_name=subject, + issuer_cert=issuer_cert, + ) + return build_certificate( + subject=subject, + public_key=public_key, + issuer_cert=issuer_cert, + issuer_private_key=issuer_private_key, + serial=serial, + not_before=not_before, + not_after=not_after, + extensions=extensions, + signature_hash=signature_hash, + ) + + +__all__ = [ + "FAR_FUTURE", + "MAX_SERIAL_BYTES", + "CertificateInfo", + "build_certificate", + "build_self_signed", + "build_signed", + "certificate_der", + "certificate_extension_specs", + "certificate_extensions", + "certificate_info", + "certificate_pem", + "certificate_public_key", + "certificate_spec", + "chain_for", + "find_issuer", + "fingerprint", + "ics_calendar", + "ics_event", + "index_entry", + "is_self_signed", + "issuer_entries", + "normalize_datetime", + "openssl_config", + "parse_certificate", + "parse_serial_hex", + "purposes", + "serial_to_hex", + "signature_algorithm_name", + "signature_hash_name", + "subject_entries", + "validate_chain", + "validation_errors", + "verify_directly_issued_by", +] diff --git a/server/packages/kca-core/src/kca/core/crls.py b/server/packages/kca-core/src/kca/core/crls.py new file mode 100644 index 0000000..6c3839a --- /dev/null +++ b/server/packages/kca-core/src/kca/core/crls.py @@ -0,0 +1,244 @@ +"""Certificate revocation list construction and parsing.""" +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Dict, Iterable, List, Mapping, Optional + +from cryptography import x509 +from cryptography.hazmat.primitives import serialization +from cryptography.x509.oid import ExtensionOID + +from .certificates import normalize_datetime, signature_algorithm_name +from .extensions import extension_rows, extension_specs +from .keys import PrivateKey, hash_algorithm + +#: XCA's revocation reason names, in OpenSSL order. +CRL_REASONS: List[Dict[str, Any]] = [ + {"name": "unspecified", "label": "Unspecified", "flag": x509.ReasonFlags.unspecified}, + {"name": "keyCompromise", "label": "Key Compromise", "flag": x509.ReasonFlags.key_compromise}, + {"name": "CACompromise", "label": "CA Compromise", "flag": x509.ReasonFlags.ca_compromise}, + {"name": "affiliationChanged", "label": "Affiliation Changed", "flag": x509.ReasonFlags.affiliation_changed}, + {"name": "superseded", "label": "Superseded", "flag": x509.ReasonFlags.superseded}, + {"name": "cessationOfOperation", "label": "Cessation Of Operation", "flag": x509.ReasonFlags.cessation_of_operation}, + {"name": "certificateHold", "label": "Certificate Hold", "flag": x509.ReasonFlags.certificate_hold}, + {"name": "removeFromCRL", "label": "Remove From CRL", "flag": x509.ReasonFlags.remove_from_crl}, + {"name": "privilegeWithdrawn", "label": "Privilege Withdrawn", "flag": x509.ReasonFlags.privilege_withdrawn}, + {"name": "AACompromise", "label": "AA Compromise", "flag": x509.ReasonFlags.aa_compromise}, +] + +_REASON_BY_NAME = {item["name"]: item for item in CRL_REASONS} +_REASON_BY_FLAG = {item["flag"]: item for item in CRL_REASONS} + + +@dataclass(frozen=True) +class CrlInfo: + issuer: x509.Name + last_update: datetime + next_update: Optional[datetime] + crl_number: Optional[int] + revoked_count: int + signature_algorithm: str + fingerprint_sha256: str + + +def reason_flag(name: str) -> x509.ReasonFlags: + return _REASON_BY_NAME.get(name, _REASON_BY_NAME["unspecified"])["flag"] + + +def reason_name(flag: x509.ReasonFlags) -> str: + return _REASON_BY_FLAG.get(flag, _REASON_BY_NAME["unspecified"])["name"] + + +def build_crl( + *, + issuer_cert: x509.Certificate, + issuer_private_key: PrivateKey, + revocations: Iterable[Mapping[str, Any]], + last_update: datetime, + next_update: datetime, + crl_number: Optional[int] = None, + signature_hash: Optional[str] = "sha256", + with_reason: bool = True, + include_aki: bool = True, + include_issuer_alt_name: bool = True, +) -> x509.CertificateRevocationList: + last_update = normalize_datetime(last_update) + next_update = normalize_datetime(next_update) + if next_update <= last_update: + raise ValueError("nextUpdate must be later than lastUpdate") + builder = ( + x509.CertificateRevocationListBuilder() + .issuer_name(issuer_cert.subject) + .last_update(last_update) + .next_update(next_update) + ) + for item in revocations: + serial = int(str(item.get("serial_hex", "0")).replace(":", ""), 16) + revoked_at = normalize_datetime(item.get("revoked_at") or last_update) + revoked = ( + x509.RevokedCertificateBuilder() + .serial_number(serial) + .revocation_date(revoked_at) + ) + if with_reason: + revoked = revoked.add_extension( + x509.CRLReason(reason_flag(str(item.get("reason", "unspecified")))), + critical=False, + ) + invalidity = item.get("invalidity_date") + if invalidity: + revoked = revoked.add_extension( + x509.InvalidityDate(normalize_datetime(invalidity)), critical=False + ) + builder = builder.add_revoked_certificate(revoked.build()) + + if crl_number is not None: + builder = builder.add_extension(x509.CRLNumber(int(crl_number)), critical=False) + + if include_aki: + try: + ski = issuer_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value + aki = x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ski) + except x509.ExtensionNotFound: + aki = x509.AuthorityKeyIdentifier.from_issuer_public_key(issuer_cert.public_key()) + builder = builder.add_extension(aki, critical=False) + + if include_issuer_alt_name: + try: + san = issuer_cert.extensions.get_extension_for_class(x509.SubjectAlternativeName).value + builder = builder.add_extension(x509.IssuerAlternativeName(list(san)), critical=False) + except x509.ExtensionNotFound: + pass + + algorithm = hash_algorithm(signature_hash, issuer_private_key) + return builder.sign(private_key=issuer_private_key, algorithm=algorithm) + + +def parse_crl(data: bytes) -> x509.CertificateRevocationList: + return x509.load_der_x509_crl(data) + + +def crl_der(crl: x509.CertificateRevocationList) -> bytes: + return crl.public_bytes(serialization.Encoding.DER) + + +def crl_pem(crl: x509.CertificateRevocationList) -> str: + return crl.public_bytes(serialization.Encoding.PEM).decode("ascii") + + +def crl_number(crl: x509.CertificateRevocationList) -> Optional[int]: + try: + return crl.extensions.get_extension_for_class(x509.CRLNumber).value.crl_number + except x509.ExtensionNotFound: + return None + + +def crl_info(crl: x509.CertificateRevocationList) -> CrlInfo: + return CrlInfo( + issuer=crl.issuer, + last_update=normalize_datetime(crl.last_update_utc), + next_update=normalize_datetime(crl.next_update_utc) if crl.next_update_utc else None, + crl_number=crl_number(crl), + revoked_count=len(list(crl)), + signature_algorithm=signature_algorithm_name(crl), + fingerprint_sha256=hashlib.sha256(crl_der(crl)).hexdigest(), + ) + + +def crl_revocations(crl: x509.CertificateRevocationList) -> List[Dict[str, Any]]: + rows = [] + for revoked in crl: + reason = None + invalidity = None + try: + reason = reason_name( + revoked.extensions.get_extension_for_class(x509.CRLReason).value.reason + ) + except x509.ExtensionNotFound: + pass + try: + invalidity = normalize_datetime( + revoked.extensions.get_extension_for_class(x509.InvalidityDate).value.invalidity_date + ).isoformat() + except x509.ExtensionNotFound: + pass + rows.append( + { + "serial_hex": format(revoked.serial_number, "x"), + "revoked_at": normalize_datetime(revoked.revocation_date_utc).isoformat(), + "invalidity_date": invalidity, + "reason": reason, + } + ) + return rows + + +def verify_crl(crl: x509.CertificateRevocationList, issuer_cert: x509.Certificate) -> bool: + if crl.issuer != issuer_cert.subject: + return False + try: + return bool(crl.is_signature_valid(issuer_cert.public_key())) + except Exception: # noqa: BLE001 + return False + + +def crl_extensions(crl: x509.CertificateRevocationList) -> List[Dict[str, Any]]: + return extension_rows(crl.extensions) + + +def crl_spec(crl: x509.CertificateRevocationList) -> Dict[str, Any]: + return { + "issuer": crl.issuer.rfc4514_string(), + "last_update": normalize_datetime(crl.last_update_utc).isoformat(), + "next_update": normalize_datetime(crl.next_update_utc).isoformat() if crl.next_update_utc else None, + "crl_number": crl_number(crl), + "revocations": crl_revocations(crl), + "extensions": extension_specs(crl.extensions), + } + + +def ics_event(crl: x509.CertificateRevocationList, name: str = "") -> str: + stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + next_update = crl.next_update_utc + if next_update is None: + return "" + start = next_update.strftime("%Y%m%dT%H%M%SZ") + summary = f"Renew CRL: {name or crl.issuer.rfc4514_string()}" + description = f"The CRL '{name or crl.issuer.rfc4514_string()}' expires on {next_update.isoformat()}." + return "\r\n".join( + [ + "BEGIN:VEVENT", + f"UID:{hashlib.sha256(crl_der(crl)).hexdigest()}@kca", + f"DTSTAMP:{stamp}", + f"DTSTART:{start}", + f"SUMMARY:{summary.replace(',', '\\,')}", + f"DESCRIPTION:{description.replace(',', '\\,')}", + "END:VEVENT", + ] + ) + + +def reason_options() -> List[Dict[str, str]]: + return [{"name": item["name"], "label": item["label"]} for item in CRL_REASONS] + + +__all__ = [ + "CRL_REASONS", + "CrlInfo", + "build_crl", + "crl_der", + "crl_extensions", + "crl_info", + "crl_number", + "crl_pem", + "crl_revocations", + "crl_spec", + "ics_event", + "parse_crl", + "reason_flag", + "reason_name", + "reason_options", + "verify_crl", +] diff --git a/server/packages/kca-core/src/kca/core/crypto.py b/server/packages/kca-core/src/kca/core/crypto.py new file mode 100644 index 0000000..888b39d --- /dev/null +++ b/server/packages/kca-core/src/kca/core/crypto.py @@ -0,0 +1,83 @@ +"""Small AES-256-GCM envelope used for private keys at rest.""" +from __future__ import annotations + +import base64 +import os +from dataclasses import dataclass +from typing import Optional + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +NONCE_BYTES = 12 +KEY_BYTES = 32 + + +class DecryptionError(ValueError): + """The ciphertext could not be authenticated with the configured key.""" + + +@dataclass(frozen=True) +class EncryptedBlob: + ciphertext: bytes + nonce: bytes + + +def decode_master_key(value: str) -> bytes: + """Decode a base64 (or hex) 32-byte master key from the environment.""" + text = (value or "").strip() + if not text: + raise ValueError("KCA_MASTER_KEY is empty") + try: + raw = base64.b64decode(text, validate=True) + except Exception: # noqa: BLE001 + try: + raw = bytes.fromhex(text) + except ValueError as exc: + raise ValueError("KCA_MASTER_KEY must be base64 or hex") from exc + if len(raw) != KEY_BYTES: + raise ValueError(f"KCA_MASTER_KEY must be {KEY_BYTES} bytes") + return raw + + +def generate_master_key() -> str: + return base64.b64encode(os.urandom(KEY_BYTES)).decode("ascii") + + +def encrypt_blob(plaintext: bytes, *, master_key: bytes, aad: bytes = b"") -> EncryptedBlob: + if len(master_key) != KEY_BYTES: + raise ValueError(f"master key must be {KEY_BYTES} bytes") + nonce = os.urandom(NONCE_BYTES) + ciphertext = AESGCM(master_key).encrypt(nonce, plaintext, aad) + return EncryptedBlob(ciphertext=ciphertext, nonce=nonce) + + +def decrypt_blob(blob: EncryptedBlob, *, master_key: bytes, aad: bytes = b"") -> bytes: + if len(master_key) != KEY_BYTES: + raise ValueError(f"master key must be {KEY_BYTES} bytes") + try: + return AESGCM(master_key).decrypt(blob.nonce, blob.ciphertext, aad) + except InvalidTag as exc: + raise DecryptionError("private key authentication failed") from exc + + +def encrypt_private_key(der: bytes, key_id: str, master_key: bytes) -> EncryptedBlob: + return encrypt_blob(der, master_key=master_key, aad=key_id.encode("utf-8")) + + +def decrypt_private_key(blob: EncryptedBlob, key_id: str, master_key: bytes) -> bytes: + return decrypt_blob(blob, master_key=master_key, aad=key_id.encode("utf-8")) + + +__all__ = [ + "DecryptionError", + "EncryptedBlob", + "KEY_BYTES", + "NONCE_BYTES", + "decode_master_key", + "decrypt_blob", + "decrypt_private_key", + "encrypt_blob", + "encrypt_private_key", + "generate_master_key", +] diff --git a/server/packages/kca-core/src/kca/core/exports.py b/server/packages/kca-core/src/kca/core/exports.py new file mode 100644 index 0000000..05febc1 --- /dev/null +++ b/server/packages/kca-core/src/kca/core/exports.py @@ -0,0 +1,324 @@ +"""Export formats for certificates, keys, requests, CRLs and templates.""" +from __future__ import annotations + +import base64 +import hashlib +import json +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Optional, Sequence + +from cryptography import x509 +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.serialization import ( + BestAvailableEncryption, + Encoding, + NoEncryption, + pkcs7, + pkcs12, +) + +from .certificates import certificate_der, certificate_pem, index_entry, ics_calendar, ics_event, openssl_config +from .crls import crl_der, crl_pem, ics_event as crl_ics_event +from .keys import ( + PrivateKey, + jwk_private, + jwk_public, + private_der_bytes, + private_pem, + public_pem, + pvk_encode_private, + ssh_private, + ssh_public, +) +from .requests import csr_der, csr_pem, openssl_config as csr_openssl_config +from .templates import template_to_xca_data + +OPENVPN_TA_BEGIN = "-----BEGIN OpenVPN Static key V1-----" +OPENVPN_TA_END = "-----END OpenVPN Static key V1-----" + + +@dataclass(frozen=True) +class ExportResult: + filename: str + content_type: str + data: bytes + + +def _safe_name(name: str, fallback: str) -> str: + cleaned = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in (name or "")).strip("._") + return cleaned or fallback + + +def _b64u(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _password_encryption(password: Optional[str]) -> serialization.KeySerializationEncryption: + if password: + return BestAvailableEncryption(password.encode()) + return NoEncryption() + + +def _pem_chain(cert: x509.Certificate, chain: Sequence[x509.Certificate]) -> bytes: + parts = [certificate_pem(cert)] + for item in chain: + if item is cert: + continue + parts.append(certificate_pem(item)) + return "".join(parts).encode("ascii") + + +def export_certificate( + cert: x509.Certificate, + export_format: str, + *, + name: str = "", + chain: Sequence[x509.Certificate] = (), + private_key: Optional[PrivateKey] = None, + password: Optional[str] = None, + tls_auth_key: Optional[str] = None, + comment: str = "", +) -> ExportResult: + fmt = (export_format or "pem").lower() + stem = _safe_name(name, "certificate") + if fmt == "pem": + return ExportResult(f"{stem}.crt", "application/x-pem-file", certificate_pem(cert).encode("ascii")) + if fmt in ("pem-chain", "chain"): + return ExportResult(f"{stem}.pem", "application/x-pem-file", _pem_chain(cert, chain)) + if fmt == "pem-key": + if private_key is None: + raise ValueError("this certificate has no private key") + data = certificate_pem(cert).encode("ascii") + private_pem(private_key) + return ExportResult(f"{stem}.pem", "application/x-pem-file", data) + if fmt == "pem-pkcs8": + if private_key is None: + raise ValueError("this certificate has no private key") + if not password: + raise ValueError("a password is required for encrypted PKCS#8 export") + data = certificate_pem(cert).encode("ascii") + private_pem(private_key, password=password) + return ExportResult(f"{stem}.pem", "application/x-pem-file", data) + if fmt in ("pkcs7", "pkcs7-chain", "pkcs7-multi"): + certs = [cert] + if fmt != "pkcs7": + certs.extend(item for item in chain if item is not cert) + data = pkcs7.serialize_certificates(certs, Encoding.DER) + return ExportResult(f"{stem}.p7b", "application/pkcs7-mime", data) + if fmt == "der": + return ExportResult(f"{stem}.cer", "application/pkix-cert", certificate_der(cert)) + if fmt in ("pkcs12", "pfx", "pkcs12-chain"): + if private_key is None: + raise ValueError("PKCS#12 export requires the private key") + cas = [item for item in chain if item is not cert] if fmt == "pkcs12-chain" else [] + data = pkcs12.serialize_key_and_certificates( + name=stem.encode(), + key=private_key, + cert=cert, + cas=cas or None, + encryption_algorithm=_password_encryption(password), + ) + return ExportResult(f"{stem}.pfx", "application/x-pkcs12", data) + if fmt == "openvpn": + if private_key is None: + raise ValueError("OpenVPN export requires the private key") + ca_cert = next((item for item in reversed(chain) if item is not cert), None) + ca_pem = certificate_pem(ca_cert) if ca_cert is not None else certificate_pem(cert) + key_pem = private_pem(private_key).decode("ascii") + text = ( + f"\n{ca_pem}\n" + f"\n{certificate_pem(cert)}\n" + f"\n{key_pem}\n" + ) + return ExportResult(f"{stem}.ovpn", "text/plain", text.encode("utf-8")) + if fmt in ("openssl-config", "config"): + return ExportResult(f"{stem}.conf", "text/plain", openssl_config(cert).encode("utf-8")) + if fmt == "index": + return ExportResult(f"{stem}.txt", "text/plain", index_entry(cert).encode("utf-8")) + if fmt == "vcalendar": + return ExportResult(f"{stem}.ics", "text/calendar", ics_calendar([ics_event(cert, name)]).encode("utf-8")) + if fmt in ("jwk", "jwk-public", "jwk-chain"): + jwk = jwk_public(cert.public_key(), kid=name or None) + der = certificate_der(cert) + jwk["x5t"] = _b64u(hashlib.sha1(der).digest()) + jwk["x5t#256"] = _b64u(hashlib.sha256(der).digest()) + if fmt == "jwk-chain": + jwk["x5c"] = [base64.b64encode(certificate_der(item)).decode("ascii") for item in [cert, *chain]] + return ExportResult(f"{stem}.jwk", "application/json", json.dumps(jwk, indent=2).encode("utf-8")) + if fmt == "tls-auth": + if not tls_auth_key: + raise ValueError("no OpenVPN tls-auth key is stored for this CA") + return ExportResult(f"{stem}.key", "text/plain", tls_auth_key.encode("ascii")) + raise ValueError(f"unsupported certificate export format {export_format!r}") + + +def export_key( + key: PrivateKey | Any, + export_format: str, + *, + name: str = "", + password: Optional[str] = None, + is_private: bool = True, +) -> ExportResult: + fmt = (export_format or "pem-public").lower() + stem = _safe_name(name, "key") + if fmt == "pem-public": + return ExportResult(f"{stem}.pem", "application/x-pem-file", public_pem(key).encode("ascii")) + if fmt in ("pem-private", "pem"): + if not is_private: + raise ValueError("this is a public key") + return ExportResult(f"{stem}.pem", "application/x-pem-file", private_pem(key)) + if fmt == "pem-encrypted": + if not is_private: + raise ValueError("this is a public key") + if not password: + raise ValueError("a password is required") + return ExportResult(f"{stem}.pem", "application/x-pem-file", private_pem(key, password=password, traditional=True)) + if fmt == "der-public": + from .keys import public_der_bytes + + return ExportResult(f"{stem}.der", "application/pkix-key", public_der_bytes(key)) + if fmt == "der-private": + if not is_private: + raise ValueError("this is a public key") + return ExportResult(f"{stem}.der", "application/pkcs8", private_der_bytes(key)) + if fmt in ("pkcs8", "pkcs8-private"): + if not is_private: + raise ValueError("this is a public key") + return ExportResult(f"{stem}.pk8", "application/x-pem-file", private_pem(key)) + if fmt == "pkcs8-encrypted": + if not is_private: + raise ValueError("this is a public key") + if not password: + raise ValueError("a password is required") + return ExportResult(f"{stem}.pk8", "application/x-pem-file", private_pem(key, password=password)) + if fmt == "ssh-public": + return ExportResult(f"{stem}.pub", "text/plain", (ssh_public(key, name) + "\n").encode("utf-8")) + if fmt == "ssh-private": + if not is_private: + raise ValueError("this is a public key") + return ExportResult(f"{stem}.priv", "text/plain", ssh_private(key, password=password)) + if fmt in ("jwk", "jwk-public"): + return ExportResult(f"{stem}.jwk", "application/json", json.dumps(jwk_public(key, kid=name or None), indent=2).encode("utf-8")) + if fmt == "jwk-private": + if not is_private: + raise ValueError("this is a public key") + return ExportResult(f"{stem}.jwk", "application/json", json.dumps(jwk_private(key, kid=name or None), indent=2).encode("utf-8")) + if fmt == "pvk": + if not is_private: + raise ValueError("this is a public key") + return ExportResult(f"{stem}.pvk", "application/octet-stream", pvk_encode_private(key)) + raise ValueError(f"unsupported key export format {export_format!r}") + + +def export_request( + csr: x509.CertificateSigningRequest, + export_format: str, + *, + name: str = "", +) -> ExportResult: + fmt = (export_format or "pem").lower() + stem = _safe_name(name, "request") + if fmt == "pem": + return ExportResult(f"{stem}.csr", "application/x-pem-file", csr_pem(csr).encode("ascii")) + if fmt == "der": + return ExportResult(f"{stem}.der", "application/pkcs10", csr_der(csr)) + if fmt in ("openssl-config", "config"): + return ExportResult(f"{stem}.conf", "text/plain", csr_openssl_config(csr).encode("utf-8")) + raise ValueError(f"unsupported request export format {export_format!r}") + + +def export_crl(crl: x509.CertificateRevocationList, export_format: str, *, name: str = "") -> ExportResult: + fmt = (export_format or "pem").lower() + stem = _safe_name(name, "crl") + if fmt == "pem": + return ExportResult(f"{stem}.crl", "application/x-pem-file", crl_pem(crl).encode("ascii")) + if fmt == "der": + return ExportResult(f"{stem}.crl", "application/pkix-crl", crl_der(crl)) + if fmt == "vcalendar": + return ExportResult(f"{stem}.ics", "text/calendar", ics_calendar([crl_ics_event(crl, name)]).encode("utf-8")) + raise ValueError(f"unsupported CRL export format {export_format!r}") + + +def export_template(template: Dict[str, Any], export_format: str = "xca", *, name: str = "") -> ExportResult: + fmt = (export_format or "xca").lower() + stem = _safe_name(name or str(template.get("name", "")), "template") + if fmt in ("xca", "pem"): + return ExportResult(f"{stem}.xca", "application/x-pem-file", template_to_xca_data(template)) + if fmt == "json": + return ExportResult(f"{stem}.json", "application/json", json.dumps(template, indent=2).encode("utf-8")) + raise ValueError(f"unsupported template export format {export_format!r}") + + +def certificate_format_options() -> List[Dict[str, str]]: + return [ + {"id": "pem", "label": "PEM", "extension": "crt"}, + {"id": "pem-chain", "label": "PEM chain", "extension": "pem"}, + {"id": "pem-key", "label": "PEM + key", "extension": "pem"}, + {"id": "pem-pkcs8", "label": "PEM + encrypted PKCS#8", "extension": "pem"}, + {"id": "pkcs7", "label": "PKCS#7", "extension": "p7b"}, + {"id": "pkcs7-chain", "label": "PKCS#7 chain", "extension": "p7b"}, + {"id": "der", "label": "DER", "extension": "cer"}, + {"id": "pkcs12", "label": "PKCS#12", "extension": "pfx"}, + {"id": "pkcs12-chain", "label": "PKCS#12 chain", "extension": "pfx"}, + {"id": "openvpn", "label": "OpenVPN", "extension": "ovpn"}, + {"id": "openssl-config", "label": "OpenSSL config", "extension": "conf"}, + {"id": "index", "label": "Certificate index file", "extension": "txt"}, + {"id": "vcalendar", "label": "vCalendar", "extension": "ics"}, + {"id": "jwk", "label": "JSON Web Key", "extension": "jwk"}, + {"id": "jwk-chain", "label": "JSON Web Key chain", "extension": "jwk"}, + {"id": "tls-auth", "label": "OpenVPN tls-auth key", "extension": "key"}, + ] + + +def key_format_options() -> List[Dict[str, str]]: + return [ + {"id": "pem-public", "label": "PEM public", "extension": "pem"}, + {"id": "pem-private", "label": "PEM private", "extension": "pem"}, + {"id": "pem-encrypted", "label": "PEM encrypted", "extension": "pem"}, + {"id": "pkcs8", "label": "PKCS#8", "extension": "pk8"}, + {"id": "pkcs8-encrypted", "label": "PKCS#8 encrypted", "extension": "pk8"}, + {"id": "der-public", "label": "DER public", "extension": "der"}, + {"id": "der-private", "label": "DER private", "extension": "der"}, + {"id": "ssh-public", "label": "SSH2 public", "extension": "pub"}, + {"id": "ssh-private", "label": "SSH2 private", "extension": "priv"}, + {"id": "jwk-public", "label": "JSON Web Key public", "extension": "jwk"}, + {"id": "jwk-private", "label": "JSON Web Key private", "extension": "jwk"}, + {"id": "pvk", "label": "PVK private", "extension": "pvk"}, + ] + + +def request_format_options() -> List[Dict[str, str]]: + return [ + {"id": "pem", "label": "PEM", "extension": "csr"}, + {"id": "der", "label": "DER", "extension": "der"}, + {"id": "openssl-config", "label": "OpenSSL config", "extension": "conf"}, + ] + + +def crl_format_options() -> List[Dict[str, str]]: + return [ + {"id": "pem", "label": "PEM", "extension": "crl"}, + {"id": "der", "label": "DER", "extension": "crl"}, + {"id": "vcalendar", "label": "vCalendar", "extension": "ics"}, + ] + + +def template_format_options() -> List[Dict[str, str]]: + return [ + {"id": "xca", "label": "XCA template", "extension": "xca"}, + {"id": "json", "label": "JSON", "extension": "json"}, + ] + + +__all__ = [ + "ExportResult", + "certificate_format_options", + "crl_format_options", + "export_certificate", + "export_crl", + "export_key", + "export_request", + "export_template", + "key_format_options", + "request_format_options", + "template_format_options", +] diff --git a/server/packages/kca-core/src/kca/core/extensions.py b/server/packages/kca-core/src/kca/core/extensions.py new file mode 100644 index 0000000..70ec512 --- /dev/null +++ b/server/packages/kca-core/src/kca/core/extensions.py @@ -0,0 +1,1188 @@ +"""X.509 v3 extension specs, builders and display helpers. + +The web UI sends a JSON specification (``spec``); this module turns it into +``cryptography`` extension objects and back. Anything the typed model does +not understand travels as a raw OID + DER hex pair, which is the web +equivalent of XCA's "Advanced" tab. +""" +from __future__ import annotations + +import ipaddress +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence + +from cryptography import x509 +from cryptography.x509.oid import ( + AuthorityInformationAccessOID, + ExtendedKeyUsageOID, + NameOID, + ObjectIdentifier, +) + +from .names import ( + DN_ATTRIBUTES, + EKU_OIDS, + common_names, + eku_name, + entries_from_json, + entries_from_name, + name_from_entries, +) + +#: Friendly names for the extensions the UI knows. +EXTENSION_NAMES: Dict[str, str] = { + "2.5.29.14": "Subject Key Identifier", + "2.5.29.15": "Key Usage", + "2.5.29.17": "Subject Alternative Name", + "2.5.29.18": "Issuer Alternative Name", + "2.5.29.19": "Basic Constraints", + "2.5.29.20": "CRL Number", + "2.5.29.21": "CRL Reason Code", + "2.5.29.24": "Invalidity Date", + "2.5.29.30": "Name Constraints", + "2.5.29.31": "CRL Distribution Points", + "2.5.29.32": "Certificate Policies", + "2.5.29.33": "Policy Mappings", + "2.5.29.35": "Authority Key Identifier", + "2.5.29.36": "Policy Constraints", + "2.5.29.37": "Extended Key Usage", + "2.5.29.46": "Freshest CRL", + "2.5.29.54": "Inhibit Any Policy", + "1.3.6.1.5.5.7.1.1": "Authority Information Access", + "1.3.6.1.5.5.7.1.11": "Subject Information Access", + "1.3.6.1.5.5.7.1.24": "TLS Feature", + "1.3.6.1.5.5.7.48.1.5": "OCSP No Check", + "2.16.840.1.113730.1.1": "Netscape Certificate Type", + "2.16.840.1.113730.1.2": "Netscape Base URL", + "2.16.840.1.113730.1.3": "Netscape Revocation URL", + "2.16.840.1.113730.1.4": "Netscape CA Revocation URL", + "2.16.840.1.113730.1.7": "Netscape Renewal URL", + "2.16.840.1.113730.1.8": "Netscape CA Policy URL", + "2.16.840.1.113730.1.12": "Netscape SSL Server Name", + "2.16.840.1.113730.1.13": "Netscape Comment", +} + +EXTENSION_ALIASES: Dict[str, str] = { + "subject_key_identifier": "2.5.29.14", + "subjectkeyidentifier": "2.5.29.14", + "key_usage": "2.5.29.15", + "keyusage": "2.5.29.15", + "subject_alt_name": "2.5.29.17", + "subjectalternativename": "2.5.29.17", + "san": "2.5.29.17", + "issuer_alt_name": "2.5.29.18", + "issueralternativename": "2.5.29.18", + "ian": "2.5.29.18", + "basic_constraints": "2.5.29.19", + "basicconstraints": "2.5.29.19", + "name_constraints": "2.5.29.30", + "nameconstraints": "2.5.29.30", + "crl_distribution_points": "2.5.29.31", + "crldistributionpoints": "2.5.29.31", + "crldp": "2.5.29.31", + "certificate_policies": "2.5.29.32", + "certificatepolicies": "2.5.29.32", + "policy_constraints": "2.5.29.36", + "policyconstraints": "2.5.29.36", + "extended_key_usage": "2.5.29.37", + "extendedkeyusage": "2.5.29.37", + "eku": "2.5.29.37", + "inhibit_any_policy": "2.5.29.54", + "inhibitanpolicy": "2.5.29.54", + "authority_info_access": "1.3.6.1.5.5.7.1.1", + "authorityinformationaccess": "1.3.6.1.5.5.7.1.1", + "aia": "1.3.6.1.5.5.7.1.1", + "tls_feature": "1.3.6.1.5.5.7.1.24", + "tlsfeature": "1.3.6.1.5.5.7.1.24", + "ocsp_no_check": "1.3.6.1.5.5.7.48.1.5", + "ocspnocheck": "1.3.6.1.5.5.7.48.1.5", +} + +KEY_USAGES: List[Dict[str, str]] = [ + {"name": "digital_signature", "label": "Digital Signature"}, + {"name": "content_commitment", "label": "Non Repudiation"}, + {"name": "key_encipherment", "label": "Key Encipherment"}, + {"name": "data_encipherment", "label": "Data Encipherment"}, + {"name": "key_agreement", "label": "Key Agreement"}, + {"name": "key_cert_sign", "label": "Certificate Sign"}, + {"name": "crl_sign", "label": "CRL Sign"}, + {"name": "encipher_only", "label": "Encipher Only"}, + {"name": "decipher_only", "label": "Decipher Only"}, +] + +KEY_USAGE_BY_NAME = {item["name"]: item for item in KEY_USAGES} +KEY_USAGE_BY_LABEL = {item["label"].lower().replace(" ", ""): item["name"] for item in KEY_USAGES} + +GENERAL_NAME_TYPES = ("dns", "ip", "uri", "email", "rid", "other_name", "directory_name", "upn") + +#: Microsoft UPN otherName OID. +MS_UPN_OID = "1.3.6.1.4.1.311.20.2.3" + +NETSCAPE_CERT_TYPES = [ + ("client", 0), + ("server", 1), + ("email", 2), + ("objsign", 3), + ("sslCA", 5), + ("emailCA", 6), + ("objCA", 7), +] + +NETSCAPE_OIDS = { + "cert_type": "2.16.840.1.113730.1.1", + "base_url": "2.16.840.1.113730.1.2", + "revocation_url": "2.16.840.1.113730.1.3", + "ca_revocation_url": "2.16.840.1.113730.1.4", + "renewal_url": "2.16.840.1.113730.1.7", + "ca_policy_url": "2.16.840.1.113730.1.8", + "ssl_server_name": "2.16.840.1.113730.1.12", + "comment": "2.16.840.1.113730.1.13", +} + + +def extension_oid(name: str) -> str: + value = (name or "").strip() + if not value: + raise ValueError("empty extension name") + if value[0].isdigit() and value.count(".") >= 1: + ObjectIdentifier(value) + return value + key = value.lower().replace("-", "_").replace(" ", "_") + if key in EXTENSION_ALIASES: + return EXTENSION_ALIASES[key] + raise ValueError(f"unknown extension {name!r}") + + +def extension_name(oid: str) -> str: + return EXTENSION_NAMES.get(oid, oid) + + +# --------------------------------------------------------------------------- +# DER helpers for legacy Netscape extensions +# --------------------------------------------------------------------------- + + +def _der_length(length: int) -> bytes: + if length < 0x80: + return bytes([length]) + encoded = length.to_bytes((length.bit_length() + 7) // 8, "big") + return bytes([0x80 | len(encoded)]) + encoded + + +def der_ia5(value: str) -> bytes: + raw = value.encode("ascii", "strict") + return b"\x16" + _der_length(len(raw)) + raw + + +def der_utf8(value: str) -> bytes: + raw = value.encode("utf-8") + return b"\x0c" + _der_length(len(raw)) + raw + + +def der_bitstring(bits: Iterable[int]) -> bytes: + set_bits = set(bits) + if not set_bits: + raw = b"\x00" + else: + highest = max(set_bits) + octets = bytearray((highest // 8) + 1) + for bit in set_bits: + octets[bit // 8] |= 1 << (7 - (bit % 8)) + unused = 7 - (highest % 8) + raw = bytes([unused]) + bytes(octets) + return b"\x03" + _der_length(len(raw)) + raw + + +def _decode_bitstring(data: bytes) -> List[int]: + if not data: + return [] + unused = data[0] + bits: List[int] = [] + for index, byte in enumerate(data[1:]): + for offset in range(8): + if index * 8 + offset >= len(data[1:]) * 8 - unused: + break + if byte & (1 << (7 - offset)): + bits.append(index * 8 + offset) + return bits + + +# --------------------------------------------------------------------------- +# General names +# --------------------------------------------------------------------------- + + +def general_name_from_spec(entry: Mapping[str, Any], subject_name: Optional[x509.Name] = None) -> Optional[x509.GeneralName]: + kind = str(entry.get("type") or entry.get("kind") or "").lower() + value = entry.get("value", "") + if kind in ("dns", "dnsname"): + if value == "copycn" or value == "DNS:copycn": + if subject_name is None: + return None + names = common_names(subject_name) + return x509.DNSName(names[0]) if names else None + return x509.DNSName(str(value)) + if kind in ("ip", "ipaddress"): + return x509.IPAddress(ipaddress.ip_address(str(value))) + if kind in ("uri", "uniformresourceidentifier"): + return x509.UniformResourceIdentifier(str(value)) + if kind in ("email", "rfc822name"): + return x509.RFC822Name(str(value)) + if kind in ("rid", "registeredid"): + return x509.RegisteredID(ObjectIdentifier(str(value))) + if kind in ("upn", "othername", "other_name"): + oid = str(entry.get("oid") or (MS_UPN_OID if kind == "upn" else "")) + if not oid: + raise ValueError("otherName entries need an OID") + if entry.get("der_hex"): + der = bytes.fromhex(str(entry["der_hex"])) + else: + der = der_utf8(str(value)) + return x509.OtherName(ObjectIdentifier(oid), der) + if kind in ("dirname", "directory_name", "directoryname"): + raw_entries = entry.get("entries") + if raw_entries: + name = name_from_entries(entries_from_json(raw_entries)) + else: + name = x509.Name.from_rfc4514_string(str(value)) + return x509.DirectoryName(name) + raise ValueError(f"unknown general-name type {kind!r}") + + +def general_name_to_spec(name: x509.GeneralName) -> Dict[str, Any]: + if isinstance(name, x509.DNSName): + return {"type": "dns", "value": name.value} + if isinstance(name, x509.IPAddress): + return {"type": "ip", "value": str(name.value)} + if isinstance(name, x509.UniformResourceIdentifier): + return {"type": "uri", "value": name.value} + if isinstance(name, x509.RFC822Name): + return {"type": "email", "value": name.value} + if isinstance(name, x509.RegisteredID): + return {"type": "rid", "value": name.value.dotted_string} + if isinstance(name, x509.OtherName): + value: Dict[str, Any] = { + "type": "other_name", + "oid": name.type_id.dotted_string, + "der_hex": name.value.hex(), + } + # Decode a UTF8String payload for display when possible. + raw = name.value + if raw and raw[0] == 0x0C and len(raw) >= 2: + try: + if raw[1] < 0x80: + value["value"] = raw[2 : 2 + raw[1]].decode("utf-8") + else: + value["value"] = raw[2:].decode("utf-8", "replace") + except Exception: # noqa: BLE001 + pass + return value + if isinstance(name, x509.DirectoryName): + return { + "type": "directory_name", + "value": name.value.rfc4514_string(), + "entries": [entry.as_dict() for entry in entries_from_name(name.value)], + } + return {"type": "unknown", "value": repr(name)} + + +def general_name_text(name: x509.GeneralName) -> str: + spec = general_name_to_spec(name) + kind = spec.get("type") + prefix = { + "dns": "DNS", + "ip": "IP", + "uri": "URI", + "email": "email", + "rid": "RID", + "other_name": "otherName", + "directory_name": "dirName", + }.get(str(kind), str(kind)) + if kind == "other_name": + return f"{prefix}:{spec.get('oid')};{spec.get('value', spec.get('der_hex', ''))}" + return f"{prefix}:{spec.get('value', '')}" + + +def parse_general_name_text(text: str, subject_name: Optional[x509.Name] = None) -> List[x509.GeneralName]: + """Parse XCA-style comma-separated general-name text.""" + result: List[x509.GeneralName] = [] + for chunk in str(text or "").split(","): + chunk = chunk.strip() + if not chunk: + continue + upper = chunk.upper() + if upper.startswith("DNS:COPYCN"): + if subject_name: + result.extend(x509.DNSName(cn) for cn in common_names(subject_name)) + continue + if upper.startswith("EMAIL:COPY") or upper.startswith("ISSUER:COPY"): + continue + if chunk.upper().startswith("UPN:"): + result.append(x509.OtherName(ObjectIdentifier(MS_UPN_OID), der_utf8(chunk[4:]))) + continue + if ":" not in chunk: + raise ValueError(f"invalid general name {chunk!r}") + prefix, value = chunk.split(":", 1) + prefix = prefix.strip().lower() + if prefix == "dns": + result.append(x509.DNSName(value.strip())) + elif prefix == "ip": + result.append(x509.IPAddress(ipaddress.ip_address(value.strip()))) + elif prefix == "uri": + result.append(x509.UniformResourceIdentifier(value.strip())) + elif prefix == "email": + result.append(x509.RFC822Name(value.strip())) + elif prefix == "rid": + result.append(x509.RegisteredID(ObjectIdentifier(value.strip()))) + elif prefix == "othername": + # otherName:oid;UTF8:value + if ";" not in value: + raise ValueError(f"invalid otherName {chunk!r}") + oid, rest = value.split(";", 1) + payload = rest.split(":", 1)[1] if ":" in rest else rest + result.append(x509.OtherName(ObjectIdentifier(oid.strip()), der_utf8(payload))) + elif prefix == "dirname": + result.append(x509.DirectoryName(x509.Name.from_rfc4514_string(value.strip()))) + else: + raise ValueError(f"unknown general-name prefix {prefix!r}") + return result + + +def general_names_to_text(names: Sequence[x509.GeneralName]) -> str: + return ", ".join(general_name_text(name) for name in names) + + +# --------------------------------------------------------------------------- +# Builders +# --------------------------------------------------------------------------- + + +def _critical(spec: Mapping[str, Any]) -> bool: + return bool(spec.get("critical", False)) + + +def _key_usage_from_spec(spec: Mapping[str, Any]) -> x509.KeyUsage: + values = {str(item).lower() for item in spec.get("usages", [])} + # Accept labels too. + normalized = set() + for item in values: + if item in KEY_USAGE_BY_NAME: + normalized.add(item) + elif item.replace(" ", "") in KEY_USAGE_BY_LABEL: + normalized.add(KEY_USAGE_BY_LABEL[item.replace(" ", "")]) + key_agreement = "key_agreement" in normalized + return x509.KeyUsage( + digital_signature="digital_signature" in normalized, + content_commitment="content_commitment" in normalized, + key_encipherment="key_encipherment" in normalized, + data_encipherment="data_encipherment" in normalized, + key_agreement=key_agreement, + key_cert_sign="key_cert_sign" in normalized, + crl_sign="crl_sign" in normalized, + encipher_only=key_agreement and "encipher_only" in normalized, + decipher_only=key_agreement and "decipher_only" in normalized, + ) + + +def _eku_from_spec(spec: Mapping[str, Any]) -> x509.ExtendedKeyUsage: + usages = [] + for item in spec.get("usages", []): + name = str(item) + if name in EKU_OIDS: + usages.append(ObjectIdentifier(EKU_OIDS[name])) + elif name in ExtendedKeyUsageOID.__members__: + usages.append(getattr(ExtendedKeyUsageOID, name)) + else: + usages.append(ObjectIdentifier(name)) + return x509.ExtendedKeyUsage(usages) + + +def _aia_from_spec(spec: Mapping[str, Any]) -> x509.AuthorityInformationAccess: + descriptions = [] + for item in spec.get("descriptions", []): + method = str(item.get("method", "ocsp")).lower() + if method == "ocsp": + method_oid = AuthorityInformationAccessOID.OCSP + elif method in ("ca_issuers", "caissuers", "ca-issuers"): + method_oid = AuthorityInformationAccessOID.CA_ISSUERS + else: + method_oid = ObjectIdentifier(method) + location_spec = item.get("location") or {"type": "uri", "value": item.get("value", "")} + location = general_name_from_spec(location_spec) + if location is not None: + descriptions.append(x509.AccessDescription(method_oid, location)) + return x509.AuthorityInformationAccess(descriptions) + + +def _policies_from_spec(spec: Mapping[str, Any]) -> x509.CertificatePolicies: + policies = [] + for item in spec.get("policies", []): + qualifiers: List[Any] = [] + for cps in item.get("cps", []) or []: + qualifiers.append(str(cps)) + notice = item.get("user_notice") + if notice: + reference = None + if notice.get("organization") or notice.get("notice_numbers"): + reference = x509.NoticeReference( + organization=str(notice.get("organization", "")), + notice_numbers=[int(n) for n in notice.get("notice_numbers", [])], + ) + qualifiers.append( + x509.UserNotice( + notice_reference=reference, + explicit_text=notice.get("explicit_text") or None, + ) + ) + policies.append( + x509.PolicyInformation( + policy_identifier=ObjectIdentifier(str(item.get("oid"))), + policy_qualifiers=qualifiers or None, + ) + ) + return x509.CertificatePolicies(policies) + + +def build_extensions( + spec: Optional[Mapping[str, Any]], + *, + public_key: Any, + subject_name: Optional[x509.Name] = None, + issuer_cert: Optional[x509.Certificate] = None, + include_aki: bool = True, +) -> List[x509.Extension]: + """Turn an extension spec into cryptography extension objects.""" + spec = spec or {} + result: List[x509.Extension] = [] + + bc = spec.get("basic_constraints") + if bc: + ca = bc.get("ca") + if ca is not None: + path_length = bc.get("path_length") + if path_length is not None: + path_length = int(path_length) + result.append( + x509.Extension( + oid=x509.oid.ExtensionOID.BASIC_CONSTRAINTS, + critical=_critical(bc), + value=x509.BasicConstraints(ca=bool(ca), path_length=path_length if ca else None), + ) + ) + + ku = spec.get("key_usage") + if ku and ku.get("usages"): + result.append( + x509.Extension( + oid=x509.oid.ExtensionOID.KEY_USAGE, + critical=_critical(ku), + value=_key_usage_from_spec(ku), + ) + ) + + eku = spec.get("extended_key_usage") + if eku and eku.get("usages"): + result.append( + x509.Extension( + oid=x509.oid.ExtensionOID.EXTENDED_KEY_USAGE, + critical=_critical(eku), + value=_eku_from_spec(eku), + ) + ) + + san = spec.get("subject_alt_name") + if san: + names = [ + name + for entry in san.get("entries", []) + if (name := general_name_from_spec(entry, subject_name)) is not None + ] + if names: + result.append( + x509.Extension( + oid=x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME, + critical=_critical(san), + value=x509.SubjectAlternativeName(names), + ) + ) + + ian = spec.get("issuer_alt_name") + if ian: + names = [ + name + for entry in ian.get("entries", []) + if (name := general_name_from_spec(entry, subject_name)) is not None + ] + if names: + result.append( + x509.Extension( + oid=x509.oid.ExtensionOID.ISSUER_ALTERNATIVE_NAME, + critical=_critical(ian), + value=x509.IssuerAlternativeName(names), + ) + ) + + if spec.get("subject_key_identifier"): + ski_spec = spec.get("subject_key_identifier") + result.append( + x509.Extension( + oid=x509.oid.ExtensionOID.SUBJECT_KEY_IDENTIFIER, + critical=_critical(ski_spec), + value=x509.SubjectKeyIdentifier.from_public_key(public_key), + ) + ) + + if include_aki and spec.get("authority_key_identifier"): + aki_spec = spec.get("authority_key_identifier") + try: + if issuer_cert is not None: + ski = issuer_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value + aki = x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier(ski) + else: + aki = x509.AuthorityKeyIdentifier.from_issuer_public_key(public_key) + except Exception: # noqa: BLE001 - fall back to the public key + aki = x509.AuthorityKeyIdentifier.from_issuer_public_key( + issuer_cert.public_key() if issuer_cert is not None else public_key + ) + result.append( + x509.Extension( + oid=x509.oid.ExtensionOID.AUTHORITY_KEY_IDENTIFIER, + critical=_critical(aki_spec), + value=aki, + ) + ) + + crldp = spec.get("crl_distribution_points") + if crldp: + points = [] + for url in crldp.get("urls", []): + points.append( + x509.DistributionPoint( + full_name=[x509.UniformResourceIdentifier(str(url))], + relative_name=None, + reasons=None, + crl_issuer=None, + ) + ) + if points: + result.append( + x509.Extension( + oid=x509.oid.ExtensionOID.CRL_DISTRIBUTION_POINTS, + critical=_critical(crldp), + value=x509.CRLDistributionPoints(points), + ) + ) + + aia = spec.get("authority_information_access") + if aia and aia.get("descriptions"): + result.append( + x509.Extension( + oid=x509.oid.ExtensionOID.AUTHORITY_INFORMATION_ACCESS, + critical=_critical(aia), + value=_aia_from_spec(aia), + ) + ) + + nc = spec.get("name_constraints") + if nc: + permitted = [ + name + for entry in nc.get("permitted", []) + if (name := general_name_from_spec(entry, subject_name)) is not None + ] + excluded = [ + name + for entry in nc.get("excluded", []) + if (name := general_name_from_spec(entry, subject_name)) is not None + ] + if permitted or excluded: + result.append( + x509.Extension( + oid=x509.oid.ExtensionOID.NAME_CONSTRAINTS, + critical=_critical(nc), + value=x509.NameConstraints( + permitted_subtrees=permitted or None, + excluded_subtrees=excluded or None, + ), + ) + ) + + policies = spec.get("certificate_policies") + if policies and policies.get("policies"): + result.append( + x509.Extension( + oid=x509.oid.ExtensionOID.CERTIFICATE_POLICIES, + critical=_critical(policies), + value=_policies_from_spec(policies), + ) + ) + + pc = spec.get("policy_constraints") + if pc and (pc.get("require_explicit_policy") is not None or pc.get("inhibit_policy_mapping") is not None): + result.append( + x509.Extension( + oid=x509.oid.ExtensionOID.POLICY_CONSTRAINTS, + critical=_critical(pc), + value=x509.PolicyConstraints( + require_explicit_policy=pc.get("require_explicit_policy"), + inhibit_policy_mapping=pc.get("inhibit_policy_mapping"), + ), + ) + ) + + iap = spec.get("inhibit_any_policy") + if iap: + result.append( + x509.Extension( + oid=x509.oid.ExtensionOID.INHIBIT_ANY_POLICY, + critical=_critical(iap), + value=x509.InhibitAnyPolicy(int(iap.get("skip_certs", 0))), + ) + ) + + tls_feature = spec.get("tls_feature") + if tls_feature and tls_feature.get("features"): + features = [] + for item in tls_feature.get("features", []): + if str(item).lower() in ("status_request", "statusrequest", "5"): + features.append(x509.TLSFeatureType.status_request) + else: + raise ValueError(f"unsupported TLS feature {item!r}") + result.append( + x509.Extension( + oid=x509.oid.ExtensionOID.TLS_FEATURE, + critical=_critical(tls_feature), + value=x509.TLSFeature(features), + ) + ) + + if spec.get("ocsp_no_check"): + result.append( + x509.Extension( + oid=x509.oid.ExtensionOID.OCSP_NO_CHECK, + critical=_critical(spec.get("ocsp_no_check")), + value=x509.OCSPNoCheck(), + ) + ) + + netscape = spec.get("netscape") + if netscape: + result.extend(_build_netscape(netscape)) + + for raw in spec.get("raw", []) or []: + oid = ObjectIdentifier(str(raw.get("oid"))) + der = bytes.fromhex(str(raw.get("der_hex", ""))) + result.append( + x509.Extension( + oid=oid, + critical=bool(raw.get("critical", False)), + value=x509.UnrecognizedExtension(oid, der), + ) + ) + + return result + + +def _build_netscape(spec: Mapping[str, Any]) -> List[x509.Extension]: + result: List[x509.Extension] = [] + cert_type = spec.get("cert_type") + if cert_type: + selected = set(str(item) for item in cert_type) + bits = [bit for name, bit in NETSCAPE_CERT_TYPES if name in selected] + oid = ObjectIdentifier(NETSCAPE_OIDS["cert_type"]) + result.append( + x509.Extension( + oid=oid, + critical=bool(spec.get("critical", False)), + value=x509.UnrecognizedExtension(oid, der_bitstring(bits)), + ) + ) + for key, oid_text in ( + ("base_url", NETSCAPE_OIDS["base_url"]), + ("revocation_url", NETSCAPE_OIDS["revocation_url"]), + ("ca_revocation_url", NETSCAPE_OIDS["ca_revocation_url"]), + ("renewal_url", NETSCAPE_OIDS["renewal_url"]), + ("ca_policy_url", NETSCAPE_OIDS["ca_policy_url"]), + ("ssl_server_name", NETSCAPE_OIDS["ssl_server_name"]), + ("comment", NETSCAPE_OIDS["comment"]), + ): + value = spec.get(key) + if value: + oid = ObjectIdentifier(oid_text) + result.append( + x509.Extension( + oid=oid, + critical=bool(spec.get("critical", False)), + value=x509.UnrecognizedExtension(oid, der_ia5(str(value))), + ) + ) + return result + + +# --------------------------------------------------------------------------- +# Parsing back to specs / display +# --------------------------------------------------------------------------- + + +def _key_usage_to_spec(value: x509.KeyUsage) -> Dict[str, Any]: + usages = [] + for name in KEY_USAGE_BY_NAME: + if name in ("encipher_only", "decipher_only") and not value.key_agreement: + continue + if getattr(value, name): + usages.append(name) + return {"usages": usages} + + +def _eku_to_spec(value: x509.ExtendedKeyUsage) -> Dict[str, Any]: + return {"usages": [eku_name(oid.dotted_string) for oid in value]} + + +def _aia_to_spec(value: x509.AuthorityInformationAccess) -> Dict[str, Any]: + descriptions = [] + for description in value: + method = description.access_method.dotted_string + if method == AuthorityInformationAccessOID.OCSP.dotted_string: + name = "ocsp" + elif method == AuthorityInformationAccessOID.CA_ISSUERS.dotted_string: + name = "ca_issuers" + else: + name = method + descriptions.append( + {"method": name, "location": general_name_to_spec(description.access_location)} + ) + return {"descriptions": descriptions} + + +def _policies_to_spec(value: x509.CertificatePolicies) -> Dict[str, Any]: + policies = [] + for policy in value: + item: Dict[str, Any] = {"oid": policy.policy_identifier.dotted_string, "cps": []} + for qualifier in policy.policy_qualifiers or []: + if isinstance(qualifier, str): + item["cps"].append(qualifier) + elif isinstance(qualifier, x509.UserNotice): + notice: Dict[str, Any] = {} + if qualifier.explicit_text: + notice["explicit_text"] = qualifier.explicit_text + if qualifier.notice_reference: + notice["organization"] = qualifier.notice_reference.organization + notice["notice_numbers"] = list(qualifier.notice_reference.notice_numbers) + item["user_notice"] = notice + policies.append(item) + return {"policies": policies} + + +def _netscape_to_spec(ext: x509.Extension) -> Optional[Dict[str, Any]]: + oid = ext.oid.dotted_string + if oid not in NETSCAPE_OIDS.values(): + return None + raw = ext.value.value if isinstance(ext.value, x509.UnrecognizedExtension) else b"" + spec: Dict[str, Any] = {"critical": ext.critical} + if oid == NETSCAPE_OIDS["cert_type"]: + bits = _decode_bitstring(raw[2:] if raw and raw[0] == 0x03 else raw) + spec["cert_type"] = [name for name, bit in NETSCAPE_CERT_TYPES if bit in bits] + else: + for key, oid_text in NETSCAPE_OIDS.items(): + if oid_text == oid and key != "cert_type": + value = raw + if value and value[0] == 0x16 and len(value) >= 2: + value = value[2 : 2 + value[1]] + spec[key] = value.decode("ascii", "replace") + break + return spec + + +def _raw_value(ext: x509.Extension) -> Optional[bytes]: + value = ext.value + if isinstance(value, x509.UnrecognizedExtension): + return value.value + return None + + +def extension_specs(extensions: Iterable[x509.Extension]) -> Dict[str, Any]: + """Convert parsed extensions back to the JSON spec used by the UI.""" + spec: Dict[str, Any] = {} + raw: List[Dict[str, Any]] = [] + netscape: Dict[str, Any] = {} + for ext in extensions: + oid = ext.oid.dotted_string + value = ext.value + if oid == x509.oid.ExtensionOID.BASIC_CONSTRAINTS.dotted_string: + spec["basic_constraints"] = { + "critical": ext.critical, + "ca": value.ca, + "path_length": value.path_length, + } + elif oid == x509.oid.ExtensionOID.KEY_USAGE.dotted_string: + item = _key_usage_to_spec(value) + item["critical"] = ext.critical + spec["key_usage"] = item + elif oid == x509.oid.ExtensionOID.EXTENDED_KEY_USAGE.dotted_string: + item = _eku_to_spec(value) + item["critical"] = ext.critical + spec["extended_key_usage"] = item + elif oid == x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME.dotted_string: + spec["subject_alt_name"] = { + "critical": ext.critical, + "entries": [general_name_to_spec(name) for name in value], + } + elif oid == x509.oid.ExtensionOID.ISSUER_ALTERNATIVE_NAME.dotted_string: + spec["issuer_alt_name"] = { + "critical": ext.critical, + "entries": [general_name_to_spec(name) for name in value], + } + elif oid == x509.oid.ExtensionOID.SUBJECT_KEY_IDENTIFIER.dotted_string: + spec["subject_key_identifier"] = { + "critical": ext.critical, + "value": value.digest.hex(), + } + elif oid == x509.oid.ExtensionOID.AUTHORITY_KEY_IDENTIFIER.dotted_string: + spec["authority_key_identifier"] = { + "critical": ext.critical, + "key_identifier": value.key_identifier.hex() if value.key_identifier else None, + "authority_cert_issuer": bool(value.authority_cert_issuer), + "authority_cert_serial_number": value.authority_cert_serial_number, + } + elif oid == x509.oid.ExtensionOID.CRL_DISTRIBUTION_POINTS.dotted_string: + urls = [] + for point in value: + for name in point.full_name or []: + if isinstance(name, x509.UniformResourceIdentifier): + urls.append(name.value) + spec["crl_distribution_points"] = {"critical": ext.critical, "urls": urls} + elif oid == x509.oid.ExtensionOID.AUTHORITY_INFORMATION_ACCESS.dotted_string: + item = _aia_to_spec(value) + item["critical"] = ext.critical + spec["authority_information_access"] = item + elif oid == x509.oid.ExtensionOID.NAME_CONSTRAINTS.dotted_string: + spec["name_constraints"] = { + "critical": ext.critical, + "permitted": [general_name_to_spec(name) for name in value.permitted_subtrees or []], + "excluded": [general_name_to_spec(name) for name in value.excluded_subtrees or []], + } + elif oid == x509.oid.ExtensionOID.CERTIFICATE_POLICIES.dotted_string: + item = _policies_to_spec(value) + item["critical"] = ext.critical + spec["certificate_policies"] = item + elif oid == x509.oid.ExtensionOID.POLICY_CONSTRAINTS.dotted_string: + spec["policy_constraints"] = { + "critical": ext.critical, + "require_explicit_policy": value.require_explicit_policy, + "inhibit_policy_mapping": value.inhibit_policy_mapping, + } + elif oid == x509.oid.ExtensionOID.INHIBIT_ANY_POLICY.dotted_string: + spec["inhibit_any_policy"] = {"critical": ext.critical, "skip_certs": value.skip_certs} + elif oid == x509.oid.ExtensionOID.TLS_FEATURE.dotted_string: + spec["tls_feature"] = { + "critical": ext.critical, + "features": [ + "status_request" if feature == x509.TLSFeatureType.status_request else str(feature) + for feature in value + ], + } + elif oid == x509.oid.ExtensionOID.OCSP_NO_CHECK.dotted_string: + spec["ocsp_no_check"] = {"critical": ext.critical} + else: + net = _netscape_to_spec(ext) + if net is not None: + netscape.update(net) + else: + raw.append( + { + "oid": oid, + "critical": ext.critical, + "der_hex": (value.value if isinstance(value, x509.UnrecognizedExtension) else b"").hex(), + } + ) + if netscape: + spec["netscape"] = netscape + if raw: + spec["raw"] = raw + return spec + + +def extension_display_value(ext: x509.Extension) -> str: + oid = ext.oid.dotted_string + value = ext.value + if oid == x509.oid.ExtensionOID.BASIC_CONSTRAINTS.dotted_string: + text = "CA:TRUE" if value.ca else "CA:FALSE" + if value.path_length is not None: + text += f", pathlen:{value.path_length}" + return text + if oid == x509.oid.ExtensionOID.KEY_USAGE.dotted_string: + names = [] + for name in KEY_USAGE_BY_NAME: + if name in ("encipher_only", "decipher_only") and not value.key_agreement: + continue + if getattr(value, name): + names.append(KEY_USAGE_BY_NAME[name]["label"]) + return ", ".join(names) if names else "(none)" + if oid == x509.oid.ExtensionOID.EXTENDED_KEY_USAGE.dotted_string: + return ", ".join(eku_name(item.dotted_string) for item in value) + if oid in ( + x509.oid.ExtensionOID.SUBJECT_ALTERNATIVE_NAME.dotted_string, + x509.oid.ExtensionOID.ISSUER_ALTERNATIVE_NAME.dotted_string, + ): + return general_names_to_text(list(value)) + if oid == x509.oid.ExtensionOID.SUBJECT_KEY_IDENTIFIER.dotted_string: + return value.digest.hex() + if oid == x509.oid.ExtensionOID.AUTHORITY_KEY_IDENTIFIER.dotted_string: + parts = [] + if value.key_identifier: + parts.append("keyid:" + value.key_identifier.hex()) + if value.authority_cert_issuer: + parts.append("issuer") + if value.authority_cert_serial_number is not None: + parts.append("serial:" + str(value.authority_cert_serial_number)) + return ", ".join(parts) + if oid == x509.oid.ExtensionOID.CRL_DISTRIBUTION_POINTS.dotted_string: + urls = [] + for point in value: + urls.extend( + name.value for name in point.full_name or [] if isinstance(name, x509.UniformResourceIdentifier) + ) + return ", ".join(urls) + if oid == x509.oid.ExtensionOID.AUTHORITY_INFORMATION_ACCESS.dotted_string: + parts = [] + for description in value: + method = description.access_method.dotted_string + method_name = { + AuthorityInformationAccessOID.OCSP.dotted_string: "OCSP", + AuthorityInformationAccessOID.CA_ISSUERS.dotted_string: "caIssuers", + }.get(method, method) + parts.append(f"{method_name};{general_name_text(description.access_location)}") + return ", ".join(parts) + if oid == x509.oid.ExtensionOID.NAME_CONSTRAINTS.dotted_string: + parts = [] + for prefix, names in (("permitted", value.permitted_subtrees), ("excluded", value.excluded_subtrees)): + for name in names or []: + parts.append(f"{prefix};{general_name_text(name)}") + return ", ".join(parts) + if oid == x509.oid.ExtensionOID.CERTIFICATE_POLICIES.dotted_string: + return ", ".join(policy.policy_identifier.dotted_string for policy in value) + if oid == x509.oid.ExtensionOID.POLICY_CONSTRAINTS.dotted_string: + parts = [] + if value.require_explicit_policy is not None: + parts.append(f"requireExplicitPolicy:{value.require_explicit_policy}") + if value.inhibit_policy_mapping is not None: + parts.append(f"inhibitPolicyMapping:{value.inhibit_policy_mapping}") + return ", ".join(parts) + if oid == x509.oid.ExtensionOID.INHIBIT_ANY_POLICY.dotted_string: + return str(value.skip_certs) + if oid == x509.oid.ExtensionOID.TLS_FEATURE.dotted_string: + return ", ".join("status_request" if item == x509.TLSFeatureType.status_request else str(item) for item in value) + if oid == x509.oid.ExtensionOID.OCSP_NO_CHECK.dotted_string: + return "noCheck" + if oid == "2.5.29.20": + return str(value.crl_number) + if oid == "2.5.29.21": + return str(value.reason) + if oid == "2.5.29.24": + return value.invalidity_date.isoformat() + net = _netscape_to_spec(ext) + if net is not None: + return ", ".join(f"{k}={v}" for k, v in net.items() if k != "critical") + if isinstance(value, x509.UnrecognizedExtension): + return value.value.hex() + return repr(value) + + +def extension_rows(extensions: Iterable[x509.Extension]) -> List[Dict[str, Any]]: + rows = [] + for ext in extensions: + value = ext.value + rows.append( + { + "oid": ext.oid.dotted_string, + "name": extension_name(ext.oid.dotted_string), + "critical": ext.critical, + "value": extension_display_value(ext), + "der_hex": (value.value if isinstance(value, x509.UnrecognizedExtension) else b"").hex(), + "spec": extension_specs([ext]), + } + ) + return rows + + +_OPENSSL_EXTENSION_NAMES = { + "2.5.29.14": "subjectKeyIdentifier", + "2.5.29.15": "keyUsage", + "2.5.29.17": "subjectAltName", + "2.5.29.18": "issuerAltName", + "2.5.29.19": "basicConstraints", + "2.5.29.30": "nameConstraints", + "2.5.29.31": "crlDistributionPoints", + "2.5.29.32": "certificatePolicies", + "2.5.29.35": "authorityKeyIdentifier", + "2.5.29.36": "policyConstraints", + "2.5.29.37": "extendedKeyUsage", + "2.5.29.54": "inhibitAnyPolicy", + "1.3.6.1.5.5.7.1.1": "authorityInfoAccess", + "1.3.6.1.5.5.7.1.24": "tlsfeature", + "1.3.6.1.5.5.7.48.1.5": "noCheck", + "2.5.29.20": "crlNumber", + "2.5.29.21": "crlReason", + "2.5.29.24": "invalityDate", +} + + +def extension_config_lines(extensions: Iterable[x509.Extension]) -> str: + lines = [] + for ext in extensions: + name = _OPENSSL_EXTENSION_NAMES.get(ext.oid.dotted_string, extension_name(ext.oid.dotted_string)) + value = extension_display_value(ext) + critical = "critical," if ext.critical else "" + if isinstance(ext.value, x509.UnrecognizedExtension): + lines.append(f"{name} = {critical}DER:{ext.value.value.hex()}") + else: + lines.append(f"{name} = {critical}{value}") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# XCA / OpenSSL text parsers used by templates and .xdb import +# --------------------------------------------------------------------------- + + +def parse_key_usage_names(text: str) -> List[str]: + result = [] + for chunk in str(text or "").split(","): + chunk = chunk.strip() + if not chunk or chunk == "critical": + continue + key = chunk.lower().replace(" ", "") + if key in KEY_USAGE_BY_LABEL: + result.append(KEY_USAGE_BY_LABEL[key]) + elif chunk in KEY_USAGE_BY_NAME: + result.append(chunk) + else: + # Accept OpenSSL short names. + aliases = { + "digitalsignature": "digital_signature", + "nonrepudiation": "content_commitment", + "keyencipherment": "key_encipherment", + "dataencipherment": "data_encipherment", + "keyagreement": "key_agreement", + "keycertsign": "key_cert_sign", + "crlsign": "crl_sign", + "encipheronly": "encipher_only", + "decipheronly": "decipher_only", + } + if key in aliases: + result.append(aliases[key]) + return result + + +def parse_eku_names(text: str) -> List[str]: + result = [] + for chunk in str(text or "").split(","): + chunk = chunk.strip() + if not chunk or chunk == "critical": + continue + result.append(chunk) + return result + + +def parse_crl_distribution_urls(text: str) -> List[str]: + urls = [] + for chunk in str(text or "").split(","): + chunk = chunk.strip() + if chunk.upper().startswith("URI:"): + urls.append(chunk[4:]) + elif chunk: + urls.append(chunk) + return urls + + +def parse_aia_descriptions(text: str) -> List[Dict[str, Any]]: + result = [] + for chunk in str(text or "").split(","): + chunk = chunk.strip() + if not chunk: + continue + if ";" in chunk: + method, rest = chunk.split(";", 1) + else: + method, rest = "ocsp", chunk + if rest.upper().startswith("URI:"): + rest = rest[4:] + result.append( + { + "method": "ca_issuers" if method.lower().startswith("caissuers") else "ocsp", + "location": {"type": "uri", "value": rest}, + } + ) + return result + + +def parse_name_constraints_text(text: str) -> Dict[str, Any]: + permitted = [] + excluded = [] + for chunk in str(text or "").split(","): + chunk = chunk.strip() + if not chunk: + continue + if ";" in chunk: + prefix, rest = chunk.split(";", 1) + else: + prefix, rest = "permitted", chunk + try: + names = parse_general_name_text(rest) + except ValueError: + continue + target = excluded if prefix.lower().startswith("excluded") else permitted + target.extend(general_name_to_spec(name) for name in names) + return {"permitted": permitted, "excluded": excluded} + + +def default_extension_spec() -> Dict[str, Any]: + return { + "basic_constraints": None, + "key_usage": {"critical": True, "usages": []}, + "extended_key_usage": {"critical": False, "usages": []}, + "subject_alt_name": {"critical": False, "entries": []}, + "issuer_alt_name": {"critical": False, "entries": []}, + "subject_key_identifier": None, + "authority_key_identifier": None, + "crl_distribution_points": {"critical": False, "urls": []}, + "authority_information_access": {"critical": False, "descriptions": []}, + "name_constraints": {"critical": True, "permitted": [], "excluded": []}, + "certificate_policies": {"critical": False, "policies": []}, + "policy_constraints": None, + "inhibit_any_policy": None, + "tls_feature": None, + "ocsp_no_check": None, + "netscape": None, + "raw": [], + } + + +__all__ = [ + "EXTENSION_ALIASES", + "EXTENSION_NAMES", + "GENERAL_NAME_TYPES", + "KEY_USAGES", + "NETSCAPE_CERT_TYPES", + "build_extensions", + "default_extension_spec", + "der_bitstring", + "der_ia5", + "der_utf8", + "extension_config_lines", + "extension_display_value", + "extension_name", + "extension_oid", + "extension_rows", + "extension_specs", + "general_name_from_spec", + "general_name_text", + "general_name_to_spec", + "general_names_to_text", + "parse_aia_descriptions", + "parse_crl_distribution_urls", + "parse_eku_names", + "parse_general_name_text", + "parse_key_usage_names", + "parse_name_constraints_text", +] diff --git a/server/packages/kca-core/src/kca/core/imports.py b/server/packages/kca-core/src/kca/core/imports.py new file mode 100644 index 0000000..aac69e8 --- /dev/null +++ b/server/packages/kca-core/src/kca/core/imports.py @@ -0,0 +1,239 @@ +"""Import parsing for PEM bundles, DER items, PKCS#7, PKCS#12 and SSH keys.""" +from __future__ import annotations + +import base64 +import re +from dataclasses import dataclass, field +from typing import Any, Dict, Iterable, List, Optional, Sequence + +from cryptography import x509 +from cryptography.hazmat.primitives.serialization import pkcs7, pkcs12 + +from .certificates import certificate_info +from .crls import crl_info +from .keys import PrivateKey, PublicKey, is_private_key, key_info, load_key_bytes +from .names import entries_from_name +from .requests import request_info +from .templates import template_from_xca_data + +_PEM_BLOCK = re.compile( + r"-----BEGIN ([^-]+)-----\s*(.*?)\s*-----END \1-----", + re.DOTALL, +) + + +@dataclass +class ParsedItem: + kind: str + name: str + summary: str + obj: Any + is_private: bool = False + warnings: List[str] = field(default_factory=list) + extra: Dict[str, Any] = field(default_factory=dict) + + @property + def fingerprint(self) -> Optional[str]: + if self.kind == "certificate": + return certificate_info(self.obj).fingerprint_sha256 + if self.kind == "key": + return key_info(self.obj).public_sha256 + if self.kind == "request": + return request_info(self.obj).fingerprint_sha256 + if self.kind == "crl": + return crl_info(self.obj).fingerprint_sha256 + return None + + +def _guess_name(obj: Any, filename: str = "") -> str: + stem = filename.rsplit("/", 1)[-1] + if "." in stem: + stem = stem.rsplit(".", 1)[0] + if isinstance(obj, x509.Certificate): + names = [str(attr.value) for attr in obj.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME)] + return names[0] if names else (stem or "certificate") + if isinstance(obj, x509.CertificateSigningRequest): + names = [str(attr.value) for attr in obj.subject.get_attributes_for_oid(x509.oid.NameOID.COMMON_NAME)] + return names[0] if names else (stem or "request") + if isinstance(obj, x509.CertificateRevocationList): + return stem or "crl" + if isinstance(obj, dict): + return str(obj.get("name") or stem or "template") + return stem or "key" + + +def _summary(obj: Any) -> str: + info: Any + if isinstance(obj, x509.Certificate): + info = certificate_info(obj) + return f"{info.subject.rfc4514_string()} · {info.serial_hex}" + if isinstance(obj, x509.CertificateSigningRequest): + info = request_info(obj) + return info.subject.rfc4514_string() + if isinstance(obj, x509.CertificateRevocationList): + info = crl_info(obj) + return f"{info.issuer.rfc4514_string()} · {info.revoked_count} revoked" + if isinstance(obj, dict): + return str(obj.get("comment") or "XCA template") + try: + info = key_info(obj) + return f"{info.key_type} {info.key_size}" + (f" {info.curve}" if info.curve else "") + except Exception: # noqa: BLE001 + return "" + + +def _key_item(key: Any, filename: str, name: Optional[str] = None) -> ParsedItem: + private = is_private_key(key) + return ParsedItem( + kind="key", + name=name or _guess_name(key, filename), + summary=_summary(key), + obj=key, + is_private=private, + ) + + +def _cert_item(cert: x509.Certificate, filename: str, name: Optional[str] = None) -> ParsedItem: + return ParsedItem( + kind="certificate", + name=name or _guess_name(cert, filename), + summary=_summary(cert), + obj=cert, + ) + + +def _csr_item(csr: x509.CertificateSigningRequest, filename: str, name: Optional[str] = None) -> ParsedItem: + return ParsedItem( + kind="request", + name=name or _guess_name(csr, filename), + summary=_summary(csr), + obj=csr, + ) + + +def _crl_item(crl: x509.CertificateRevocationList, filename: str, name: Optional[str] = None) -> ParsedItem: + return ParsedItem( + kind="crl", + name=name or _guess_name(crl, filename), + summary=_summary(crl), + obj=crl, + ) + + +def _template_item(template: Dict[str, Any], filename: str) -> ParsedItem: + return ParsedItem( + kind="template", + name=str(template.get("name") or _guess_name(template, filename)), + summary=_summary(template), + obj=template, + ) + + +def parse_pkcs12(data: bytes, filename: str = "", password: Optional[str] = None) -> List[ParsedItem]: + try: + key, cert, cas = pkcs12.load_key_and_certificates(data, password.encode() if password else None) + except Exception as exc: # noqa: BLE001 + raise ValueError("unable to read the PKCS#12 file (wrong password or unsupported encryption)") from exc + items: List[ParsedItem] = [] + name = _guess_name(cert, filename) if cert is not None else _guess_name(None, filename) + if cert is not None: + items.append(_cert_item(cert, filename, name)) + if key is not None: + items.append(_key_item(key, filename, f"{name}_key")) + for index, extra in enumerate(cas or []): + items.append(_cert_item(extra, filename, f"{name}_ca_{index}")) + if not items: + raise ValueError("the PKCS#12 file contains no keys or certificates") + return items + + +def parse_pkcs7(data: bytes, filename: str = "") -> List[ParsedItem]: + certs: Sequence[x509.Certificate] = [] + try: + certs = pkcs7.load_der_pkcs7_certificates(data) + except Exception: # noqa: BLE001 + try: + certs = pkcs7.load_pem_pkcs7_certificates(data) + except Exception as exc: # noqa: BLE001 + raise ValueError("unable to read the PKCS#7 file") from exc + if not certs: + raise ValueError("the PKCS#7 file contains no certificates") + return [_cert_item(cert, filename, f"{_guess_name(cert, filename)}_{index}" if len(certs) > 1 else None) for index, cert in enumerate(certs)] + + +def parse_pem_bundle(data: bytes, filename: str = "", password: Optional[str] = None) -> List[ParsedItem]: + text = data.decode("utf-8", "replace") + blocks = list(_PEM_BLOCK.finditer(text)) + if not blocks: + return [] + items: List[ParsedItem] = [] + for match in blocks: + label = match.group(1).strip().upper() + block = match.group(0).encode("utf-8") + try: + if label == "CERTIFICATE": + items.append(_cert_item(x509.load_pem_x509_certificate(block), filename)) + elif label in ("X509 CRL", "CRL"): + items.append(_crl_item(x509.load_pem_x509_crl(block), filename)) + elif label in ("CERTIFICATE REQUEST", "NEW CERTIFICATE REQUEST"): + items.append(_csr_item(x509.load_pem_x509_csr(block), filename)) + elif label in ("PKCS7", "PKCS #7"): + items.extend(parse_pkcs7(block, filename)) + elif label == "XCA TEMPLATE": + items.append(_template_item(template_from_xca_data(block), filename)) + elif "PRIVATE KEY" in label or "PUBLIC KEY" in label or label in ("OPENSSH PRIVATE KEY", "SSH2 PUBLIC KEY"): + key, private = load_key_bytes(block, password) + items.append(_key_item(key, filename)) + else: + # Unknown PEM label: try every parser before giving up. + items.extend(_try_all(block, filename, password)) + except Exception as exc: # noqa: BLE001 + raise ValueError(f"unable to import the {label} block: {exc}") from exc + return items + + +def _try_all(data: bytes, filename: str, password: Optional[str]) -> List[ParsedItem]: + for parser in ( + lambda: [_cert_item(x509.load_der_x509_certificate(data), filename)], + lambda: [_csr_item(x509.load_der_x509_csr(data), filename)], + lambda: [_crl_item(x509.load_der_x509_crl(data), filename)], + lambda: parse_pkcs12(data, filename, password), + lambda: parse_pkcs7(data, filename), + lambda: [_key_item(load_key_bytes(data, password)[0], filename)], + ): + try: + return parser() + except Exception: # noqa: BLE001 + continue + raise ValueError("unrecognized data") + + +def parse_file(data: bytes, filename: str = "", password: Optional[str] = None) -> List[ParsedItem]: + """Parse any supported file, detecting PEM vs DER automatically.""" + if not data: + raise ValueError("the file is empty") + pem_items = parse_pem_bundle(data, filename, password) + if pem_items: + return pem_items + return _try_all(data, filename, password) + + +def parse_text(text: str, filename: str = "", password: Optional[str] = None) -> List[ParsedItem]: + return parse_file(text.encode("utf-8"), filename, password) + + +def parse_xca_database(data: bytes, filename: str = "", password: Optional[str] = None) -> List[ParsedItem]: + from .xca_db import import_xca_database + + return import_xca_database(data, filename, password) + + +__all__ = [ + "ParsedItem", + "parse_file", + "parse_pem_bundle", + "parse_pkcs7", + "parse_pkcs12", + "parse_text", + "parse_xca_database", +] diff --git a/server/packages/kca-core/src/kca/core/keys.py b/server/packages/kca-core/src/kca/core/keys.py new file mode 100644 index 0000000..0a2560e --- /dev/null +++ b/server/packages/kca-core/src/kca/core/keys.py @@ -0,0 +1,543 @@ +"""Key generation, parsing and serialization. + +All key material handled here is in memory. The application layer is +responsible for encrypting private keys before they touch the database. +""" +from __future__ import annotations + +import base64 +import hashlib +import io +import struct +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import dsa, ec, ed25519, rsa +from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + PublicFormat, +) + +PrivateKey = ( + rsa.RSAPrivateKey + | dsa.DSAPrivateKey + | ec.EllipticCurvePrivateKey + | ed25519.Ed25519PrivateKey +) +PublicKey = ( + rsa.RSAPublicKey + | dsa.DSAPublicKey + | ec.EllipticCurvePublicKey + | ed25519.Ed25519PublicKey +) + +#: Curves offered by the UI. ``cryptography`` supports all of them on every +#: platform we target. +EC_CURVES: Dict[str, ec.EllipticCurve] = { + "secp192r1": ec.SECP192R1(), + "secp224r1": ec.SECP224R1(), + "secp256r1": ec.SECP256R1(), + "secp384r1": ec.SECP384R1(), + "secp521r1": ec.SECP521R1(), + "secp256k1": ec.SECP256K1(), + "brainpoolP256r1": ec.BrainpoolP256R1(), + "brainpoolP384r1": ec.BrainpoolP384R1(), + "brainpoolP512r1": ec.BrainpoolP512R1(), +} + +_CURVE_NAMES = {curve.name: name for name, curve in EC_CURVES.items()} + +KEY_TYPES = ("rsa", "dsa", "ec", "ed25519") + +DEFAULT_KEY_SIZE = 2048 +MAX_KEY_SIZE = 8192 +MIN_KEY_SIZE = 512 + +#: Hash algorithms selectable for signatures. Ed25519 has no separate hash. +HASH_ALGORITHMS: Dict[str, Optional[hashes.HashAlgorithm]] = { + "sha1": hashes.SHA1(), + "sha224": hashes.SHA224(), + "sha256": hashes.SHA256(), + "sha384": hashes.SHA384(), + "sha512": hashes.SHA512(), +} + + +@dataclass(frozen=True) +class KeyInfo: + key_type: str + key_size: int + curve: Optional[str] + public_der: bytes + private_der: Optional[bytes] + is_private: bool + + @property + def public_sha256(self) -> str: + return hashlib.sha256(self.public_der).hexdigest() + + @property + def fingerprint_sha256(self) -> str: + return self.public_sha256 + + +def _require_private(key: Any) -> PrivateKey: + if not isinstance( + key, (rsa.RSAPrivateKey, dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey, ed25519.Ed25519PrivateKey) + ): + raise ValueError("a private key is required") + return key + + +def _public_key(key: PrivateKey | PublicKey) -> PublicKey: + if isinstance(key, (rsa.RSAPublicKey, dsa.DSAPublicKey, ec.EllipticCurvePublicKey, ed25519.Ed25519PublicKey)): + return key + return key.public_key() + + +def generate_key(key_type: str, size: int = DEFAULT_KEY_SIZE, curve: Optional[str] = None) -> PrivateKey: + """Generate a private key of the requested type.""" + key_type = (key_type or "").lower() + if key_type == "rsa": + if not (MIN_KEY_SIZE <= int(size) <= MAX_KEY_SIZE): + raise ValueError(f"RSA key size must be between {MIN_KEY_SIZE} and {MAX_KEY_SIZE} bits") + return rsa.generate_private_key(public_exponent=65537, key_size=int(size)) + if key_type == "dsa": + if not (MIN_KEY_SIZE <= int(size) <= 3072): + raise ValueError("DSA key size must be between 512 and 3072 bits") + return dsa.generate_private_key(key_size=int(size)) + if key_type == "ec": + curve_name = curve or "secp256r1" + if curve_name not in EC_CURVES: + raise ValueError(f"unsupported EC curve {curve_name!r}") + return ec.generate_private_key(EC_CURVES[curve_name]) + if key_type == "ed25519": + return ed25519.Ed25519PrivateKey.generate() + raise ValueError(f"unsupported key type {key_type!r}") + + +def key_type_name(key: PrivateKey | PublicKey) -> str: + if isinstance(key, (rsa.RSAPrivateKey, rsa.RSAPublicKey)): + return "RSA" + if isinstance(key, (dsa.DSAPrivateKey, dsa.DSAPublicKey)): + return "DSA" + if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)): + return "EC" + if isinstance(key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)): + return "ED25519" + raise ValueError("unsupported key type") + + +def key_size(key: PrivateKey | PublicKey) -> int: + if isinstance(key, (rsa.RSAPrivateKey, rsa.RSAPublicKey, dsa.DSAPrivateKey, dsa.DSAPublicKey)): + return key.key_size + if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)): + return key.curve.key_size + if isinstance(key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)): + return 256 + raise ValueError("unsupported key type") + + +def curve_name(key: PrivateKey | PublicKey) -> Optional[str]: + if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)): + return _CURVE_NAMES.get(key.curve.name, key.curve.name) + return None + + +def is_private_key(key: Any) -> bool: + return isinstance( + key, (rsa.RSAPrivateKey, dsa.DSAPrivateKey, ec.EllipticCurvePrivateKey, ed25519.Ed25519PrivateKey) + ) + + +def public_der(key: PrivateKey | PublicKey) -> bytes: + return _public_key(key).public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo) + + +def private_der(key: PrivateKey) -> bytes: + return _require_private(key).private_bytes(Encoding.DER, PrivateFormat.PKCS8, NoEncryption()) + + +def key_info(key: PrivateKey | PublicKey) -> KeyInfo: + private = is_private_key(key) + return KeyInfo( + key_type=key_type_name(key), + key_size=key_size(key), + curve=curve_name(key), + public_der=public_der(key), + private_der=private_der(key) if private else None, + is_private=private, + ) + + +def possible_hashes(key: PrivateKey | PublicKey) -> List[str]: + if isinstance(key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)): + return [] + if isinstance(key, (dsa.DSAPrivateKey, dsa.DSAPublicKey)): + return ["sha1", "sha224", "sha256"] + if isinstance(key, (ec.EllipticCurvePrivateKey, ec.EllipticCurvePublicKey)): + # Every supported curve is large enough for SHA-512 in practice. + return ["sha1", "sha224", "sha256", "sha384", "sha512"] + return ["sha1", "sha224", "sha256", "sha384", "sha512"] + + +def hash_algorithm(name: Optional[str], key: PrivateKey | PublicKey) -> Optional[hashes.HashAlgorithm]: + """The signature hash for ``key``; ``None`` means the key's built-in hash.""" + if isinstance(key, (ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey)): + return None + name = (name or "sha256").lower() + if name not in HASH_ALGORITHMS: + raise ValueError(f"unsupported hash algorithm {name!r}") + return HASH_ALGORITHMS[name] + + +# --------------------------------------------------------------------------- +# Serialization +# --------------------------------------------------------------------------- + + +def public_pem(key: PrivateKey | PublicKey) -> str: + return _public_key(key).public_bytes(Encoding.PEM, PublicFormat.SubjectPublicKeyInfo).decode("ascii") + + +def public_der_bytes(key: PrivateKey | PublicKey) -> bytes: + return public_der(key) + + +def private_pem(key: PrivateKey, password: Optional[str] = None, traditional: bool = False) -> bytes: + key = _require_private(key) + encryption: serialization.KeySerializationEncryption + encryption = NoEncryption() if not password else serialization.BestAvailableEncryption(password.encode()) + fmt = PrivateFormat.TraditionalOpenSSL if traditional else PrivateFormat.PKCS8 + if traditional and isinstance(key, ed25519.Ed25519PrivateKey): + raise ValueError("Ed25519 keys cannot be exported in traditional PEM format") + return key.private_bytes(Encoding.PEM, fmt, encryption) + + +def private_der_bytes(key: PrivateKey, password: Optional[str] = None) -> bytes: + key = _require_private(key) + encryption: serialization.KeySerializationEncryption + encryption = NoEncryption() if not password else serialization.BestAvailableEncryption(password.encode()) + return key.private_bytes(Encoding.DER, PrivateFormat.PKCS8, encryption) + + +def ssh_public(key: PrivateKey | PublicKey, comment: str = "") -> str: + encoded = _public_key(key).public_bytes(Encoding.OpenSSH, PublicFormat.OpenSSH).decode("ascii") + return f"{encoded} {comment}".strip() + + +def ssh_private(key: PrivateKey, password: Optional[str] = None) -> bytes: + """OpenSSH private key format, via paramiko.""" + import paramiko + + key = _require_private(key) + if isinstance(key, rsa.RSAPrivateKey): + pkey = paramiko.RSAKey(key=key) + elif isinstance(key, dsa.DSAPrivateKey): + pkey = paramiko.DSSKey(key=key) + elif isinstance(key, ec.EllipticCurvePrivateKey): + pkey = paramiko.ECDSAKey(key=key) + elif isinstance(key, ed25519.Ed25519PrivateKey): + pkey = paramiko.Ed25519Key(key=key) + else: # pragma: no cover - defensive + raise ValueError("unsupported key type for SSH export") + buffer = io.StringIO() + pkey.write_private_key(buffer, password=password) + return buffer.getvalue().encode("utf-8") + + +# --------------------------------------------------------------------------- +# JSON Web Key (RFC 7517) +# --------------------------------------------------------------------------- + + +def _b64u(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _int_bytes(value: int) -> bytes: + return value.to_bytes((value.bit_length() + 7) // 8 or 1, "big") + + +def jwk_public(key: PrivateKey | PublicKey, *, kid: Optional[str] = None) -> Dict[str, Any]: + pub = _public_key(key) + if isinstance(pub, rsa.RSAPublicKey): + numbers = pub.public_numbers() + result: Dict[str, Any] = { + "kty": "RSA", + "n": _b64u(_int_bytes(numbers.n)), + "e": _b64u(_int_bytes(numbers.e)), + } + elif isinstance(pub, ec.EllipticCurvePublicKey): + numbers = pub.public_numbers() + curve = { + "secp256r1": "P-256", + "secp384r1": "P-384", + "secp521r1": "P-521", + "secp256k1": "secp256k1", + }.get(pub.curve.name, pub.curve.name) + result = { + "kty": "EC", + "crv": curve, + "x": _b64u(numbers.x.to_bytes((numbers.x.bit_length() + 7) // 8 or 1, "big")), + "y": _b64u(numbers.y.to_bytes((numbers.y.bit_length() + 7) // 8 or 1, "big")), + } + elif isinstance(pub, ed25519.Ed25519PublicKey): + result = { + "kty": "OKP", + "crv": "Ed25519", + "x": _b64u(pub.public_bytes(Encoding.Raw, PublicFormat.Raw)), + } + else: + raise ValueError("JWK is not supported for this key type") + if kid: + result["kid"] = kid + return result + + +def jwk_private(key: PrivateKey, *, kid: Optional[str] = None) -> Dict[str, Any]: + key = _require_private(key) + result = jwk_public(key, kid=kid) + if isinstance(key, rsa.RSAPrivateKey): + numbers = key.private_numbers() + result.update( + { + "d": _b64u(_int_bytes(numbers.d)), + "p": _b64u(_int_bytes(numbers.p)), + "q": _b64u(_int_bytes(numbers.q)), + "dp": _b64u(_int_bytes(numbers.dmp1)), + "dq": _b64u(_int_bytes(numbers.dmq1)), + "qi": _b64u(_int_bytes(numbers.iqmp)), + } + ) + elif isinstance(key, ec.EllipticCurvePrivateKey): + result["d"] = _b64u(_int_bytes(key.private_numbers().private_value)) + elif isinstance(key, ed25519.Ed25519PrivateKey): + result["d"] = _b64u(key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())) + else: + raise ValueError("JWK private export is not supported for this key type") + return result + + +# --------------------------------------------------------------------------- +# Microsoft PVK (RSA) +# --------------------------------------------------------------------------- + +_PVK_MAGIC = 0xB0B5F11E +_PVK_KEYTYPE = 2 +_CALG_RSA_KEYX = 0x00002400 +_RSA2 = 0x32415352 + + +def pvk_encode_private(key: PrivateKey) -> bytes: + if not isinstance(key, rsa.RSAPrivateKey): + raise ValueError("PVK export supports RSA keys only") + numbers = key.private_numbers() + pub = numbers.public_numbers + size = (pub.n.bit_length() + 7) // 8 + blob = struct.pack(" rsa.RSAPrivateKey: + if len(data) < 24: + raise ValueError("not a PVK file") + magic, _reserved, key_type, encrypted, encrypted_len, blob_len = struct.unpack(" Tuple[PrivateKey | PublicKey, bool]: + """Load a key from PEM/DER/OpenSSH/PVK bytes. + + Returns ``(key, is_private)``. Raises :class:`ValueError` when nothing + matches. + """ + password_bytes = password.encode() if password else None + errors: List[str] = [] + + # PEM private / public + try: + key = serialization.load_pem_private_key(data, password=password_bytes) + return key, True + except Exception as exc: # noqa: BLE001 - probing + errors.append(str(exc)) + try: + key = serialization.load_pem_public_key(data) + return key, False + except Exception as exc: # noqa: BLE001 + errors.append(str(exc)) + + # DER private / public + try: + key = serialization.load_der_private_key(data, password=password_bytes) + return key, True + except Exception as exc: # noqa: BLE001 + errors.append(str(exc)) + try: + key = serialization.load_der_public_key(data) + return key, False + except Exception as exc: # noqa: BLE001 + errors.append(str(exc)) + + # OpenSSH private / public + try: + key = serialization.load_ssh_private_key(data, password=password_bytes) + return key, True + except Exception as exc: # noqa: BLE001 + errors.append(str(exc)) + try: + key = serialization.load_ssh_public_key(data) + return key, False + except Exception as exc: # noqa: BLE001 + errors.append(str(exc)) + + # Microsoft PVK + try: + return pvk_decode_private(data), True + except Exception as exc: # noqa: BLE001 + errors.append(str(exc)) + + # Encrypted OpenSSH keys are not supported by cryptography; paramiko can + # read them. Convert the common key types back to cryptography objects. + try: + return _load_ssh_private_with_paramiko(data, password), True + except Exception as exc: # noqa: BLE001 + errors.append(str(exc)) + + raise ValueError("unable to parse key: " + (errors[-1] if errors else "unknown format")) + + +def _load_ssh_private_with_paramiko(data: bytes, password: Optional[str]) -> PrivateKey: + import paramiko + + text = data.decode("utf-8", "replace") + last: Optional[Exception] = None + for cls in (paramiko.RSAKey, paramiko.ECDSAKey, paramiko.Ed25519Key, paramiko.DSSKey): + try: + pkey = cls.from_private_key(io.StringIO(text), password=password) + break + except Exception as exc: # noqa: BLE001 + last = exc + else: # pragma: no cover - defensive + raise ValueError(f"unable to parse OpenSSH private key: {last}") + # Paramiko keeps the cryptography signing key on its instances. This is + # stable across 3.x and avoids re-implementing key conversion. + signing_key = getattr(pkey, "_signing_key", None) + if signing_key is not None and is_private_key(signing_key): + return signing_key + if isinstance(pkey, paramiko.RSAKey): + pub = rsa.RSAPublicNumbers(e=pkey.e, n=pkey.n) + return rsa.RSAPrivateNumbers( + p=pkey.p, q=pkey.q, d=pkey.d, dmp1=pkey.dmp1, dmq1=pkey.dmq1, iqmp=pkey.iqmp, + public_numbers=pub, + ).private_key() + if isinstance(pkey, paramiko.DSSKey): + pub = dsa.DSAPublicNumbers(parameter_numbers=dsa.DSAParameterNumbers(pkey.p, pkey.q, pkey.g), y=pkey.y) + return dsa.DSAPrivateNumbers(x=pkey.x, public_numbers=pub).private_key() + raise ValueError("unable to convert the SSH private key") + + +def public_key_from_der(data: bytes) -> PublicKey: + return serialization.load_der_public_key(data) + + +def private_key_from_der(data: bytes, password: Optional[str] = None) -> PrivateKey: + return serialization.load_der_private_key(data, password=password.encode() if password else None) + + +def public_key_sha256(key: PrivateKey | PublicKey) -> str: + return hashlib.sha256(public_der(key)).hexdigest() + + +def key_type_options() -> List[Dict[str, Any]]: + return [ + {"type": "rsa", "label": "RSA", "sizes": [1024, 2048, 3072, 4096, 8192]}, + {"type": "dsa", "label": "DSA", "sizes": [1024, 2048, 3072]}, + {"type": "ec", "label": "EC", "curves": list(EC_CURVES)}, + {"type": "ed25519", "label": "ED25519", "sizes": []}, + ] + + +__all__ = [ + "DEFAULT_KEY_SIZE", + "EC_CURVES", + "HASH_ALGORITHMS", + "KEY_TYPES", + "KeyInfo", + "MAX_KEY_SIZE", + "MIN_KEY_SIZE", + "PrivateKey", + "PublicKey", + "curve_name", + "generate_key", + "hash_algorithm", + "is_private_key", + "jwk_private", + "jwk_public", + "key_info", + "key_size", + "key_type_name", + "key_type_options", + "load_key_bytes", + "possible_hashes", + "private_der", + "private_der_bytes", + "private_key_from_der", + "private_pem", + "public_der", + "public_der_bytes", + "public_key_from_der", + "public_key_sha256", + "public_pem", + "pvk_decode_private", + "pvk_encode_private", + "ssh_private", + "ssh_public", +] diff --git a/server/packages/kca-core/src/kca/core/names.py b/server/packages/kca-core/src/kca/core/names.py new file mode 100644 index 0000000..9681954 --- /dev/null +++ b/server/packages/kca-core/src/kca/core/names.py @@ -0,0 +1,260 @@ +"""Distinguished names and the small OID registry used by the UI. + +XCA stores a subject as an ordered sequence of X.509 name entries and lets the +user add the same attribute more than once. ``cryptography`` models that as a +sequence of RDNs; we build one RDN per entry so order and duplicates survive a +round-trip. +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + +from cryptography import x509 +from cryptography.x509.oid import NameOID, ObjectIdentifier + +#: Common distinguished-name attributes. The value is ``(short name, long name)``. +DN_ATTRIBUTES: Dict[str, Tuple[str, str]] = { + "2.5.4.3": ("CN", "commonName"), + "2.5.4.4": ("SN", "surname"), + "2.5.4.5": ("serialNumber", "serialNumber"), + "2.5.4.6": ("C", "countryName"), + "2.5.4.7": ("L", "localityName"), + "2.5.4.8": ("ST", "stateOrProvinceName"), + "2.5.4.9": ("street", "streetAddress"), + "2.5.4.10": ("O", "organizationName"), + "2.5.4.11": ("OU", "organizationalUnitName"), + "2.5.4.12": ("title", "title"), + "2.5.4.17": ("postalCode", "postalCode"), + "2.5.4.42": ("GN", "givenName"), + "2.5.4.43": ("initials", "initials"), + "2.5.4.44": ("generationQualifier", "generationQualifier"), + "2.5.4.46": ("dnQualifier", "dnQualifier"), + "2.5.4.65": ("pseudonym", "pseudonym"), + "2.5.4.97": ("organizationIdentifier", "organizationIdentifier"), + "1.2.840.113549.1.9.1": ("emailAddress", "emailAddress"), + "0.9.2342.19200300.100.1.1": ("UID", "userId"), + "0.9.2342.19200300.100.1.25": ("DC", "domainComponent"), +} + +#: Aliases accepted from the API / template format. +DN_ALIASES: Dict[str, str] = { + "cn": "2.5.4.3", + "commonname": "2.5.4.3", + "surname": "2.5.4.4", + "sn": "2.5.4.4", + "serialnumber": "2.5.4.5", + "c": "2.5.4.6", + "countryname": "2.5.4.6", + "l": "2.5.4.7", + "localityname": "2.5.4.7", + "st": "2.5.4.8", + "stateorprovincename": "2.5.4.8", + "street": "2.5.4.9", + "streetaddress": "2.5.4.9", + "o": "2.5.4.10", + "organizationname": "2.5.4.10", + "ou": "2.5.4.11", + "organizationalunitname": "2.5.4.11", + "title": "2.5.4.12", + "postalcode": "2.5.4.17", + "gn": "2.5.4.42", + "givenname": "2.5.4.42", + "initials": "2.5.4.43", + "generationqualifier": "2.5.4.44", + "dnqualifier": "2.5.4.46", + "pseudonym": "2.5.4.65", + "organizationidentifier": "2.5.4.97", + "email": "1.2.840.113549.1.9.1", + "emailaddress": "1.2.840.113549.1.9.1", + "uid": "0.9.2342.19200300.100.1.1", + "userid": "0.9.2342.19200300.100.1.1", + "dc": "0.9.2342.19200300.100.1.25", + "domaincomponent": "0.9.2342.19200300.100.1.25", +} + +#: Extended-key-usage names accepted in specs and templates. +EKU_OIDS: Dict[str, str] = { + "serverAuth": "1.3.6.1.5.5.7.3.1", + "clientAuth": "1.3.6.1.5.5.7.3.2", + "codeSigning": "1.3.6.1.5.5.7.3.3", + "emailProtection": "1.3.6.1.5.5.7.3.4", + "ipsecEndSystem": "1.3.6.1.5.5.7.3.5", + "ipsecTunnel": "1.3.6.1.5.5.7.3.6", + "ipsecUser": "1.3.6.1.5.5.7.3.7", + "timeStamping": "1.3.6.1.5.5.7.3.8", + "OCSPSigning": "1.3.6.1.5.5.7.3.9", + "id-kp-eapOverPPP": "1.3.6.1.5.5.7.3.13", + "id-kp-eapOverLAN": "1.3.6.1.5.5.7.3.14", + "secureShellClient": "1.3.6.1.5.5.7.3.21", + "secureShellServer": "1.3.6.1.5.5.7.3.22", + "msCodeInd": "1.3.6.1.4.1.311.2.1.21", + "msCodeCom": "1.3.6.1.4.1.311.2.1.22", + "msCTLSign": "1.3.6.1.4.1.311.10.3.1", + "msSGC": "1.3.6.1.4.1.311.10.3.3", + "msEFS": "1.3.6.1.4.1.311.10.3.4", + "nsSGC": "2.16.840.1.113730.4.1", + "pkInitKDC": "1.3.6.1.5.2.3.5", + "adobePDFSigning": "1.2.840.113583.1.1.5", + "msofficeSigning": "1.3.6.1.4.1.311.10.3.12", +} + +_EKU_BY_OID = {oid: name for name, oid in EKU_OIDS.items()} + + +@dataclass(frozen=True) +class DnEntry: + oid: str + name: str + value: str + + def as_dict(self) -> Dict[str, str]: + return {"oid": self.oid, "name": self.name, "value": self.value} + + +def resolve_oid(value: str) -> str: + """Resolve a short name, long name or dotted OID to a dotted OID.""" + value = (value or "").strip() + if not value: + raise ValueError("empty attribute name") + if value[0].isdigit() and value.count(".") >= 1: + # Validate through cryptography so malformed OIDs fail early. + ObjectIdentifier(value) + return value + key = value.lower().replace(" ", "").replace("-", "").replace("_", "") + if key in DN_ALIASES: + return DN_ALIASES[key] + if value in DN_ATTRIBUTES: + return value + raise ValueError(f"unknown distinguished-name attribute {value!r}") + + +def oid_name(oid: str) -> str: + """The short display name for an attribute OID.""" + return DN_ATTRIBUTES.get(oid, (oid, oid))[0] + + +def oid_long_name(oid: str) -> str: + return DN_ATTRIBUTES.get(oid, (oid, oid))[1] + + +def entry_from_dict(entry: Mapping[str, Any]) -> DnEntry: + oid = resolve_oid(str(entry.get("oid") or entry.get("name") or "")) + value = entry.get("value", "") + if not isinstance(value, str): + value = str(value) + return DnEntry(oid=oid, name=oid_name(oid), value=value.strip()) + + +def entries_from_json(entries: Iterable[Mapping[str, Any]]) -> List[DnEntry]: + return [entry_from_dict(entry) for entry in entries] + + +def name_from_entries(entries: Sequence[DnEntry]) -> x509.Name: + """Build a Name, one RDN per entry, preserving order and duplicates.""" + rdns = [] + for entry in entries: + if not entry.value: + continue + rdns.append( + x509.RelativeDistinguishedName( + [x509.NameAttribute(ObjectIdentifier(entry.oid), entry.value)] + ) + ) + return x509.Name(rdns) + + +def entries_from_name(name: x509.Name) -> List[DnEntry]: + """Flatten a Name into ordered entries (one per attribute).""" + result: List[DnEntry] = [] + for rdn in name.rdns: + for attribute in rdn: + oid = attribute.oid.dotted_string + result.append(DnEntry(oid=oid, name=oid_name(oid), value=str(attribute.value))) + return result + + +def name_from_der(data: bytes) -> x509.Name: + return x509.Name.from_bytes(data) + + +def name_to_der(name: x509.Name) -> bytes: + return name.public_bytes() + + +def rfc4514(name: x509.Name) -> str: + return name.rfc4514_string() + + +def one_line(name: x509.Name) -> str: + """A stable, human-readable one-line rendering (RFC 4514 with commas).""" + return name.rfc4514_string() + + +def name_hash(name: x509.Name) -> str: + """SHA-256 of the DER encoding, used for fast issuer lookup.""" + import hashlib + + return hashlib.sha256(name.public_bytes()).hexdigest() + + +def common_names(name: x509.Name) -> List[str]: + return [str(a.value) for a in name.get_attributes_for_oid(NameOID.COMMON_NAME)] + + +def eku_name(oid: str) -> str: + return _EKU_BY_OID.get(oid, oid) + + +def eku_oid(name: str) -> str: + if name in EKU_OIDS: + return EKU_OIDS[name] + return resolve_oid(name) + + +def name_attribute_options() -> List[Dict[str, str]]: + """The DN attributes offered by the UI, in the usual XCA order.""" + order = [ + "2.5.4.6", + "2.5.4.8", + "2.5.4.7", + "2.5.4.10", + "2.5.4.11", + "2.5.4.3", + "1.2.840.113549.1.9.1", + ] + seen = set(order) + ordered = order + [oid for oid in DN_ATTRIBUTES if oid not in seen] + return [ + {"oid": oid, "name": DN_ATTRIBUTES[oid][0], "long_name": DN_ATTRIBUTES[oid][1]} + for oid in ordered + ] + + +def eku_options() -> List[Dict[str, str]]: + return [{"name": name, "oid": oid} for name, oid in EKU_OIDS.items()] + + +__all__ = [ + "DN_ALIASES", + "DN_ATTRIBUTES", + "EKU_OIDS", + "DnEntry", + "common_names", + "eku_name", + "eku_oid", + "eku_options", + "entries_from_json", + "entries_from_name", + "entry_from_dict", + "name_attribute_options", + "name_from_der", + "name_from_entries", + "name_hash", + "name_to_der", + "oid_long_name", + "oid_name", + "one_line", + "resolve_oid", + "rfc4514", +] diff --git a/server/packages/kca-core/src/kca/core/py.typed b/server/packages/kca-core/src/kca/core/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/server/packages/kca-core/src/kca/core/requests.py b/server/packages/kca-core/src/kca/core/requests.py new file mode 100644 index 0000000..8d1f35c --- /dev/null +++ b/server/packages/kca-core/src/kca/core/requests.py @@ -0,0 +1,224 @@ +"""PKCS#10 certificate signing request helpers.""" +from __future__ import annotations + +import hashlib +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Mapping, Optional + +from cryptography import x509 +from cryptography.hazmat.primitives import serialization +from cryptography.x509.oid import ObjectIdentifier + +from .extensions import build_extensions, extension_config_lines, extension_rows, extension_specs +from .keys import PrivateKey, PublicKey, hash_algorithm, public_der +from .names import entries_from_name, name_from_entries + +CHALLENGE_PASSWORD_OID = "1.2.840.113549.1.9.7" +UNSTRUCTURED_NAME_OID = "1.2.840.113549.1.9.2" + +ATTRIBUTE_NAMES = { + CHALLENGE_PASSWORD_OID: "Challenge Password", + UNSTRUCTURED_NAME_OID: "Unstructured Name", +} + + +@dataclass(frozen=True) +class RequestInfo: + subject: x509.Name + signature_algorithm: str + public_key_der: bytes + fingerprint_sha256: str + attributes: List[Dict[str, Any]] + + +def build_csr( + *, + subject: x509.Name, + private_key: PrivateKey, + extension_spec: Optional[Mapping[str, Any]] = None, + signature_hash: Optional[str] = "sha256", + attributes: Optional[Mapping[str, str]] = None, +) -> x509.CertificateSigningRequest: + public_key = private_key.public_key() + extensions = build_extensions( + extension_spec, + public_key=public_key, + subject_name=subject, + include_aki=False, + ) + # A CSR must not carry an Authority Key Identifier or an issuer alt name; + # XCA removes them as well. + extensions = [ + ext + for ext in extensions + if ext.oid + not in ( + x509.oid.ExtensionOID.AUTHORITY_KEY_IDENTIFIER, + x509.oid.ExtensionOID.ISSUER_ALTERNATIVE_NAME, + ) + ] + builder = x509.CertificateSigningRequestBuilder().subject_name(subject) + for extension in extensions: + builder = builder.add_extension(extension.value, extension.critical) + for oid, value in (attributes or {}).items(): + if value: + builder = builder.add_attribute(ObjectIdentifier(oid), str(value).encode("utf-8")) + algorithm = hash_algorithm(signature_hash, private_key) + return builder.sign(private_key, algorithm) + + +def parse_csr(data: bytes) -> x509.CertificateSigningRequest: + return x509.load_der_x509_csr(data) + + +def csr_der(csr: x509.CertificateSigningRequest) -> bytes: + return csr.public_bytes(serialization.Encoding.DER) + + +def csr_pem(csr: x509.CertificateSigningRequest) -> str: + return csr.public_bytes(serialization.Encoding.PEM).decode("ascii") + + +def verify_csr(csr: x509.CertificateSigningRequest) -> bool: + return bool(csr.is_signature_valid) + + +def request_info(csr: x509.CertificateSigningRequest) -> RequestInfo: + public_key = csr.public_key() + return RequestInfo( + subject=csr.subject, + signature_algorithm=_signature_name(csr), + public_key_der=public_der(public_key), + fingerprint_sha256=hashlib.sha256(csr_der(csr)).hexdigest(), + attributes=attribute_rows(csr), + ) + + +_SIGNATURE_OIDS = { + "1.2.840.113549.1.1.5": "sha1WithRSAEncryption", + "1.2.840.113549.1.1.11": "sha256WithRSAEncryption", + "1.2.840.113549.1.1.12": "sha384WithRSAEncryption", + "1.2.840.113549.1.1.13": "sha512WithRSAEncryption", + "1.2.840.113549.1.1.14": "sha224WithRSAEncryption", + "1.2.840.10045.4.1": "ecdsa-with-SHA1", + "1.2.840.10045.4.3.2": "ecdsa-with-SHA256", + "1.2.840.10045.4.3.3": "ecdsa-with-SHA384", + "1.2.840.10045.4.3.4": "ecdsa-with-SHA512", + "1.3.101.112": "Ed25519", +} + + +def _signature_name(csr: x509.CertificateSigningRequest) -> str: + oid = csr.signature_algorithm_oid + return _SIGNATURE_OIDS.get(oid.dotted_string, getattr(oid, "name", None) or oid.dotted_string) + + +def _decode_attribute_value(value: bytes) -> str: + # cryptography returns the DER-encoded value bytes. Try UTF-8 first, + # then strip a simple ASN.1 string tag/length header. + try: + return value.decode("utf-8") + except UnicodeDecodeError: + pass + if len(value) >= 2 and value[0] in (0x0C, 0x13, 0x16, 0x1E): + length = value[1] + offset = 2 + if length & 0x80: + count = length & 0x7F + length = int.from_bytes(value[2 : 2 + count], "big") + offset = 2 + count + raw = value[offset : offset + length] + if value[0] == 0x1E: + return raw.decode("utf-16-be", "replace") + return raw.decode("utf-8", "replace") + return value.hex() + + +def attribute_rows(csr: x509.CertificateSigningRequest) -> List[Dict[str, Any]]: + rows = [] + for attribute in csr.attributes: + oid = attribute.oid.dotted_string + rows.append( + { + "oid": oid, + "name": ATTRIBUTE_NAMES.get(oid, oid), + "values": [_decode_attribute_value(attribute.value)], + } + ) + return rows + + +def attribute_values(csr: x509.CertificateSigningRequest) -> Dict[str, str]: + result = {} + for row in attribute_rows(csr): + if row["values"]: + result[row["oid"]] = row["values"][0] + return result + + +def csr_spec(csr: x509.CertificateSigningRequest) -> Dict[str, Any]: + return { + "subject": [entry.as_dict() for entry in entries_from_name(csr.subject)], + "signature_hash": _hash_name(csr), + "extensions": extension_specs(csr.extensions), + "attributes": attribute_values(csr), + "public_key_der": public_der(csr.public_key()).hex(), + } + + +def _hash_name(csr: x509.CertificateSigningRequest) -> Optional[str]: + try: + algorithm = csr.signature_hash_algorithm + except Exception: # noqa: BLE001 - Ed25519 + return None + return algorithm.name if algorithm is not None else None + + +def csr_extensions(csr: x509.CertificateSigningRequest) -> List[Dict[str, Any]]: + return extension_rows(csr.extensions) + + +def csr_extension_specs(csr: x509.CertificateSigningRequest) -> Dict[str, Any]: + return extension_specs(csr.extensions) + + +def csr_public_key(csr: x509.CertificateSigningRequest) -> PublicKey: + return csr.public_key() + + +def openssl_config(csr: x509.CertificateSigningRequest) -> str: + lines = [ + "[req]", + "distinguished_name = req_distinguished_name", + "prompt = no", + "", + "[req_distinguished_name]", + ] + for entry in entries_from_name(csr.subject): + lines.append(f"{entry.name} = {entry.value}") + lines.extend(["", "[v3_ext]"]) + config = extension_config_lines(csr.extensions) + if config: + lines.append(config) + return "\n".join(lines) + "\n" + + +__all__ = [ + "ATTRIBUTE_NAMES", + "CHALLENGE_PASSWORD_OID", + "RequestInfo", + "UNSTRUCTURED_NAME_OID", + "attribute_rows", + "attribute_values", + "build_csr", + "csr_der", + "csr_extension_specs", + "csr_extensions", + "csr_pem", + "csr_public_key", + "csr_spec", + "openssl_config", + "parse_csr", + "request_info", + "verify_csr", +] diff --git a/server/packages/kca-core/src/kca/core/templates.py b/server/packages/kca-core/src/kca/core/templates.py new file mode 100644 index 0000000..a251805 --- /dev/null +++ b/server/packages/kca-core/src/kca/core/templates.py @@ -0,0 +1,583 @@ +"""XCA-style certificate/request templates. + +A template is a subject plus a set of extension defaults and a relative +validity range. Built-in templates mirror XCA's ``CA``, ``TLS_server`` and +``TLS_client`` templates; user templates are stored as JSON by the app. +""" +from __future__ import annotations + +import base64 +import binascii +import struct +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence + +from cryptography import x509 + +from .extensions import ( + default_extension_spec, + parse_aia_descriptions, + parse_crl_distribution_urls, + parse_eku_names, + parse_general_name_text, + parse_key_usage_names, + parse_name_constraints_text, +) +from .names import DnEntry, entries_from_json, entries_from_name, name_from_entries + +TEMPLATE_VERSION = 10 +PEM_STRING_XCA_TEMPLATE = "XCA TEMPLATE" + + +@dataclass(frozen=True) +class Template: + id: str + name: str + comment: str + subject: List[Dict[str, str]] + settings: Dict[str, Any] + builtin: bool = False + + def as_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "comment": self.comment, + "subject": self.subject, + "settings": self.settings, + "builtin": self.builtin, + } + + +def default_settings() -> Dict[str, Any]: + extensions = default_extension_spec() + extensions["basic_constraints"] = {"critical": True, "ca": None, "path_length": None} + return { + "extensions": extensions, + "validity": {"amount": 365, "unit": "days", "midnight": False, "no_expiration": False}, + } + + +def _base_settings() -> Dict[str, Any]: + return default_settings() + + +def builtin_templates() -> List[Template]: + ca = _base_settings() + ca["extensions"].update( + { + "basic_constraints": {"critical": True, "ca": True, "path_length": None}, + "key_usage": {"critical": True, "usages": ["key_cert_sign", "crl_sign"]}, + "extended_key_usage": {"critical": False, "usages": []}, + "subject_key_identifier": {"critical": False}, + "authority_key_identifier": {"critical": False}, + } + ) + ca["validity"] = {"amount": 10, "unit": "years", "midnight": False, "no_expiration": False} + + server = _base_settings() + server["extensions"].update( + { + "basic_constraints": {"critical": True, "ca": False, "path_length": None}, + "key_usage": { + "critical": True, + "usages": ["digital_signature", "content_commitment", "key_encipherment", "key_agreement"], + }, + "extended_key_usage": {"critical": False, "usages": ["serverAuth"]}, + "subject_alt_name": {"critical": False, "entries": [{"type": "dns", "value": "copycn"}]}, + "subject_key_identifier": {"critical": False}, + "authority_key_identifier": {"critical": False}, + } + ) + server["validity"] = {"amount": 365, "unit": "days", "midnight": False, "no_expiration": False} + + client = _base_settings() + client["extensions"].update( + { + "basic_constraints": {"critical": True, "ca": False, "path_length": None}, + "key_usage": { + "critical": True, + "usages": ["digital_signature", "key_encipherment", "data_encipherment", "key_agreement"], + }, + "extended_key_usage": {"critical": False, "usages": ["clientAuth"]}, + "subject_key_identifier": {"critical": False}, + "authority_key_identifier": {"critical": False}, + } + ) + client["validity"] = {"amount": 365, "unit": "days", "midnight": False, "no_expiration": False} + + return [ + Template( + id="builtin:ca", + name="CA", + comment="Default XCA CA template", + subject=[], + settings=ca, + builtin=True, + ), + Template( + id="builtin:tls-server", + name="TLS server", + comment="Default XCA TLS server template", + subject=[], + settings=server, + builtin=True, + ), + Template( + id="builtin:tls-client", + name="TLS client", + comment="Default XCA TLS client template", + subject=[], + settings=client, + builtin=True, + ), + ] + + +def apply_template( + template: Mapping[str, Any], + target: Mapping[str, Any], + *, + apply_subject: bool = True, + apply_extensions: bool = True, +) -> Dict[str, Any]: + """Merge a template into a certificate/request creation spec.""" + result = dict(target) + if apply_subject and template.get("subject"): + result["subject"] = [dict(item) for item in template["subject"]] + if apply_extensions: + settings = template.get("settings") or {} + extensions = settings.get("extensions") or {} + merged = dict(result.get("extensions") or {}) + for key, value in extensions.items(): + if value is not None: + merged[key] = value + result["extensions"] = merged + if settings.get("validity"): + result["validity"] = dict(settings["validity"]) + return result + + +def template_from_certificate(cert: x509.Certificate, name: str, comment: str = "") -> Dict[str, Any]: + from .certificates import certificate_extension_specs + + return { + "name": name, + "comment": comment, + "subject": [entry.as_dict() for entry in entries_from_name(cert.subject)], + "settings": { + "extensions": certificate_extension_specs(cert), + "validity": {"amount": 365, "unit": "days", "midnight": False, "no_expiration": False}, + }, + } + + +def template_from_request(csr: x509.CertificateSigningRequest, name: str, comment: str = "") -> Dict[str, Any]: + from .requests import csr_extension_specs + + return { + "name": name, + "comment": comment, + "subject": [entry.as_dict() for entry in entries_from_name(csr.subject)], + "settings": { + "extensions": csr_extension_specs(csr), + "validity": {"amount": 365, "unit": "days", "midnight": False, "no_expiration": False}, + }, + } + + +# --------------------------------------------------------------------------- +# XCA template binary format (Qt QDataStream, version 10) +# --------------------------------------------------------------------------- + + +def _qstring(value: Optional[str]) -> bytes: + if value is None: + return struct.pack(">I", 0xFFFFFFFF) + raw = value.encode("utf-16-be") + return struct.pack(">I", len(raw)) + raw + + +def _read_qstring(data: bytes, offset: int) -> tuple[Optional[str], int]: + if offset + 4 > len(data): + raise ValueError("truncated template") + length = struct.unpack(">I", data[offset : offset + 4])[0] + offset += 4 + if length == 0xFFFFFFFF: + return None, offset + if offset + length > len(data): + raise ValueError("truncated template string") + raw = data[offset : offset + length] + offset += length + return raw.decode("utf-16-be", "replace"), offset + + +def _qmap(values: Mapping[str, str]) -> bytes: + # QMap iterates in key order. + keys = sorted(values) + out = struct.pack(">I", len(keys)) + for key in keys: + out += _qstring(key) + _qstring(values[key]) + return out + + +def _read_qmap(data: bytes, offset: int) -> tuple[Dict[str, str], int]: + if offset + 4 > len(data): + raise ValueError("truncated template map") + count = struct.unpack(">I", data[offset : offset + 4])[0] + offset += 4 + result: Dict[str, str] = {} + for _ in range(count): + key, offset = _read_qstring(data, offset) + value, offset = _read_qstring(data, offset) + if key is not None: + result[key] = value or "" + return result, offset + + +def _der_name_length(data: bytes) -> int: + if len(data) < 2 or data[0] != 0x30: + raise ValueError("template does not start with an X.509 name") + first = data[1] + if first < 0x80: + return 2 + first + count = first & 0x7F + if count == 0 or len(data) < 2 + count: + raise ValueError("invalid DER length") + length = int.from_bytes(data[2 : 2 + count], "big") + return 2 + count + length + + +def template_to_xca_data(template: Mapping[str, Any]) -> bytes: + """Serialize a template in XCA's PEM-like template format.""" + subject = name_from_entries(entries_from_json(template.get("subject") or [])) + name_der = subject.public_bytes() + settings = template_to_xca_settings(template) + settings["internal_name"] = str(template.get("name") or "") + settings["internal_comment"] = str(template.get("comment") or "") + data = name_der + _qmap(settings) + payload = struct.pack(">I", len(data)) + struct.pack(">I", TEMPLATE_VERSION) + data + body = base64.encodebytes(payload).decode("ascii").replace("\n", "") + wrapped = "\n".join(body[i : i + 64] for i in range(0, len(body), 64)) + return ( + f"-----BEGIN {PEM_STRING_XCA_TEMPLATE}-----\n" + f"{wrapped}\n" + f"-----END {PEM_STRING_XCA_TEMPLATE}-----\n" + ).encode("ascii") + + +def template_from_xca_data(data: bytes | str) -> Dict[str, Any]: + """Parse XCA's PEM-like template format (version 10).""" + if isinstance(data, bytes): + text = data.decode("ascii", "replace") + else: + text = data + begin = f"-----BEGIN {PEM_STRING_XCA_TEMPLATE}-----" + end = f"-----END {PEM_STRING_XCA_TEMPLATE}-----" + if begin not in text or end not in text: + raise ValueError("not an XCA template") + body = text.split(begin, 1)[1].split(end, 1)[0] + try: + payload = base64.b64decode("".join(body.split())) + except binascii.Error as exc: + raise ValueError("invalid base64 in XCA template") from exc + if len(payload) < 8: + raise ValueError("truncated XCA template") + _size, version = struct.unpack(">II", payload[:8]) + if version != TEMPLATE_VERSION: + raise ValueError(f"unsupported XCA template version {version}") + content = payload[8:] + name_len = _der_name_length(content) + name = x509.Name.from_bytes(content[:name_len]) + settings, _offset = _read_qmap(content, name_len) + name_value = settings.pop("internal_name", "") + comment = settings.pop("internal_comment", "") + settings_json = xca_settings_to_template(settings) + return { + "name": name_value or "XCA template", + "comment": comment, + "subject": [entry.as_dict() for entry in entries_from_name(name)], + "settings": settings_json, + } + + +def template_from_xca_payload(data: bytes, version: int = TEMPLATE_VERSION) -> Dict[str, Any]: + """Parse the raw ``templates.template`` payload from an XCA database.""" + if version != TEMPLATE_VERSION: + raise ValueError(f"unsupported XCA template version {version}") + name_len = _der_name_length(data) + name = x509.Name.from_bytes(data[:name_len]) + settings, _offset = _read_qmap(data, name_len) + name_value = settings.pop("internal_name", "") + comment = settings.pop("internal_comment", "") + settings_json = xca_settings_to_template(settings) + return { + "name": name_value or "XCA template", + "comment": comment, + "subject": [entry.as_dict() for entry in entries_from_name(name)], + "settings": settings_json, + } + + +def xca_settings_to_template(settings: Mapping[str, str]) -> Dict[str, Any]: + """Convert XCA's ``pki_temp`` settings to the KCA template JSON.""" + result = default_settings() + extensions = result["extensions"] + extensions["basic_constraints"] = None + ca = settings.get("ca", "") + if ca == "1": + extensions["basic_constraints"] = { + "critical": settings.get("bcCritical") == "1", + "ca": True, + "path_length": int(settings["basicPath"]) if settings.get("basicPath", "").isdigit() else None, + } + elif ca == "2": + extensions["basic_constraints"] = { + "critical": settings.get("bcCritical") == "1", + "ca": False, + "path_length": None, + } + + key_use = int(settings.get("keyUse") or 0) + usage_names = [ + name + for index, name in enumerate( + [ + "digital_signature", + "content_commitment", + "key_encipherment", + "data_encipherment", + "key_agreement", + "key_cert_sign", + "crl_sign", + "encipher_only", + "decipher_only", + ] + ) + if key_use & (1 << index) + ] + extensions["key_usage"] = { + "critical": settings.get("kuCritical") == "1", + "usages": usage_names, + } + extensions["extended_key_usage"] = { + "critical": settings.get("ekuCritical") == "1", + "usages": parse_eku_names(settings.get("eKeyUse", "")), + } + if settings.get("subAltName"): + try: + extensions["subject_alt_name"] = { + "critical": False, + "entries": [ + {"type": "dns", "value": "copycn"} + ] + if settings.get("subAltName") == "DNS:copycn" + else [ + {"type": _kind, "value": _value} + for _kind, _value in _parse_general_text(settings["subAltName"]) + ], + } + except ValueError: + extensions["subject_alt_name"] = {"critical": False, "entries": []} + if settings.get("issAltName"): + try: + extensions["issuer_alt_name"] = { + "critical": False, + "entries": [ + {"type": _kind, "value": _value} + for _kind, _value in _parse_general_text(settings["issAltName"]) + ], + } + except ValueError: + pass + if settings.get("crlDist"): + extensions["crl_distribution_points"] = { + "critical": False, + "urls": parse_crl_distribution_urls(settings["crlDist"]), + } + if settings.get("authInfAcc"): + extensions["authority_information_access"] = { + "critical": False, + "descriptions": parse_aia_descriptions(settings["authInfAcc"]), + } + if settings.get("nameCons"): + extensions["name_constraints"] = { + "critical": True, + **parse_name_constraints_text(settings["nameCons"]), + } + extensions["subject_key_identifier"] = {"critical": False} if settings.get("subKey") == "1" else None + extensions["authority_key_identifier"] = {"critical": False} if settings.get("authKey") == "1" else None + if settings.get("OCSPstaple") == "1": + extensions["tls_feature"] = {"critical": False, "features": ["status_request"]} + ns_type = int(settings.get("nsCertType") or 0) + ns_names = [ + name + for index, name in enumerate(["client", "server", "email", "objsign", "sslCA", "emailCA", "objCA"]) + if ns_type & (1 << index) + ] + netscape: Dict[str, Any] = {} + if ns_names: + netscape["cert_type"] = ns_names + ns_fields = { + "nsComment": "comment", + "nsBaseUrl": "base_url", + "nsRevocationUrl": "revocation_url", + "nsCARevocationUrl": "ca_revocation_url", + "nsRenewalUrl": "renewal_url", + "nsCaPolicyUrl": "ca_policy_url", + "nsSslServerName": "ssl_server_name", + } + for source, target in ns_fields.items(): + if settings.get(source): + netscape[target] = settings[source] + if netscape: + extensions["netscape"] = netscape + if settings.get("adv_ext"): + # Kept for display/export; the typed editor does not interpret the + # OpenSSL config text. + result["advanced_config"] = settings["adv_ext"] + + valid_n = int(settings.get("validN") or 365) + valid_m = int(settings.get("validM") or 0) + result["validity"] = { + "amount": valid_n, + "unit": "days" if valid_m == 0 else ("months" if valid_m == 1 else "years"), + "midnight": settings.get("validMidn") == "1", + "no_expiration": settings.get("noWellDefinedExpDate") == "1", + } + return result + + +def _parse_general_text(text: str) -> List[tuple[str, str]]: + result = [] + for chunk in str(text).split(","): + chunk = chunk.strip() + if not chunk: + continue + if ":" not in chunk: + continue + prefix, value = chunk.split(":", 1) + prefix = prefix.strip().lower() + kind = { + "dns": "dns", + "ip": "ip", + "uri": "uri", + "email": "email", + "rid": "rid", + "othername": "other_name", + "dirname": "directory_name", + }.get(prefix) + if kind: + result.append((kind, value.strip())) + return result + + +def template_to_xca_settings(template: Mapping[str, Any]) -> Dict[str, str]: + """Convert the KCA template JSON to XCA's ``pki_temp`` settings map.""" + settings = dict(template.get("settings") or {}) + extensions = settings.get("extensions") or {} + result: Dict[str, str] = {} + bc = extensions.get("basic_constraints") or {} + if bc.get("ca") is True: + result["ca"] = "1" + result["bcCritical"] = "1" if bc.get("critical") else "0" + result["basicPath"] = str(bc.get("path_length") or "") + elif bc.get("ca") is False: + result["ca"] = "2" + result["bcCritical"] = "1" if bc.get("critical") else "0" + result["basicPath"] = "" + else: + result["ca"] = "" + result["bcCritical"] = "" + result["basicPath"] = "" + usage_index = { + "digital_signature": 0, + "content_commitment": 1, + "key_encipherment": 2, + "data_encipherment": 3, + "key_agreement": 4, + "key_cert_sign": 5, + "crl_sign": 6, + "encipher_only": 7, + "decipher_only": 8, + } + ku = extensions.get("key_usage") or {} + result["keyUse"] = str(sum(1 << usage_index[name] for name in ku.get("usages", []) if name in usage_index)) + result["kuCritical"] = "1" if ku.get("critical") else "0" + eku = extensions.get("extended_key_usage") or {} + result["eKeyUse"] = ", ".join(eku.get("usages", [])) + result["ekuCritical"] = "1" if eku.get("critical") else "0" + result["subKey"] = "1" if extensions.get("subject_key_identifier") else "0" + result["authKey"] = "1" if extensions.get("authority_key_identifier") else "0" + san = extensions.get("subject_alt_name") or {} + result["subAltName"] = _general_text(san.get("entries", [])) + ian = extensions.get("issuer_alt_name") or {} + result["issAltName"] = _general_text(ian.get("entries", [])) + result["crlDist"] = ", ".join( + f"URI:{url}" for url in (extensions.get("crl_distribution_points") or {}).get("urls", []) + ) + result["authInfAcc"] = ", ".join( + f"{'OCSP' if item.get('method') == 'ocsp' else 'caIssuers'};URI:{(item.get('location') or {}).get('value', '')}" + for item in (extensions.get("authority_information_access") or {}).get("descriptions", []) + ) + nc = extensions.get("name_constraints") or {} + result["nameCons"] = ", ".join( + [f"permitted;{_one_general(item)}" for item in nc.get("permitted", [])] + + [f"excluded;{_one_general(item)}" for item in nc.get("excluded", [])] + ) + result["OCSPstaple"] = "1" if extensions.get("tls_feature") else "0" + ns = extensions.get("netscape") or {} + ns_index = {name: index for index, name in enumerate(["client", "server", "email", "objsign", "sslCA", "emailCA", "objCA"])} + result["nsCertType"] = str(sum(1 << ns_index[name] for name in ns.get("cert_type", []) if name in ns_index)) + result["nsComment"] = str(ns.get("comment", "")) + result["nsBaseUrl"] = str(ns.get("base_url", "")) + result["nsRevocationUrl"] = str(ns.get("revocation_url", "")) + result["nsCARevocationUrl"] = str(ns.get("ca_revocation_url", "")) + result["nsRenewalUrl"] = str(ns.get("renewal_url", "")) + result["nsCaPolicyUrl"] = str(ns.get("ca_policy_url", "")) + result["nsSslServerName"] = str(ns.get("ssl_server_name", "")) + result["adv_ext"] = str(settings.get("advanced_config", "")) + validity = settings.get("validity") or {} + result["validN"] = str(validity.get("amount", 365)) + result["validM"] = {"days": "0", "months": "1", "years": "2"}.get(str(validity.get("unit", "days")), "0") + result["validMidn"] = "1" if validity.get("midnight") else "0" + result["noWellDefinedExpDate"] = "1" if validity.get("no_expiration") else "0" + return result + + +def _general_text(entries: Sequence[Mapping[str, Any]]) -> str: + return ", ".join(_one_general(entry) for entry in entries) + + +def _one_general(entry: Mapping[str, Any]) -> str: + kind = str(entry.get("type", "dns")) + value = entry.get("value", "") + prefix = { + "dns": "DNS", + "ip": "IP", + "uri": "URI", + "email": "email", + "rid": "RID", + "other_name": "otherName", + "directory_name": "dirName", + }.get(kind, kind) + if kind == "other_name": + return f"{prefix}:{entry.get('oid', '')};UTF8:{value}" + return f"{prefix}:{value}" + + +__all__ = [ + "PEM_STRING_XCA_TEMPLATE", + "TEMPLATE_VERSION", + "Template", + "apply_template", + "builtin_templates", + "default_settings", + "template_from_certificate", + "template_from_request", + "template_from_xca_data", + "template_from_xca_payload", + "template_to_xca_data", + "template_to_xca_settings", + "xca_settings_to_template", +] diff --git a/server/packages/kca-core/src/kca/core/xca_db.py b/server/packages/kca-core/src/kca/core/xca_db.py new file mode 100644 index 0000000..0fcfdc1 --- /dev/null +++ b/server/packages/kca-core/src/kca/core/xca_db.py @@ -0,0 +1,334 @@ +"""Import an XCA SQLite database (``.xdb``). + +XCA stores every cryptographic object as base64 DER in a small relational +schema. This reader supports schema 7/8 (XCA 2.x), software keys and the +normal item types; PKCS#11 token rows are skipped because a browser app cannot +use a card reader. Private keys are decrypted with the database password +(individual key passwords are tried with the same value and otherwise the +public part is imported). +""" +from __future__ import annotations + +import base64 +import hashlib +import sqlite3 +import tempfile +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.ciphers import Cipher, modes +from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES + +from .certificates import parse_certificate +from .crls import parse_crl +from .imports import ParsedItem +from .keys import is_private_key, load_key_bytes +from .requests import parse_csr +from .templates import template_from_xca_payload + +_PKI_SOURCE = { + 0: "unknown", + 1: "imported", + 2: "generated", + 3: "transformed", + 4: "token", + 5: "legacy", + 6: "renewed", +} + + +def _parse_date(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + text = str(value).strip() + if not text: + return None + for fmt in ("%Y%m%d%H%M%SZ", "%Y%m%d%H%M%S"): + try: + return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc) + except ValueError: + continue + return None + + +def _evp_bytes_to_key(password: bytes, salt: bytes, key_len: int = 24) -> bytes: + """OpenSSL's EVP_BytesToKey with SHA-1 and one iteration.""" + result = b"" + previous = b"" + while len(result) < key_len: + digest = hashlib.sha1() + digest.update(previous) + digest.update(password) + digest.update(salt) + previous = digest.digest() + result += previous + return result[:key_len] + + +def _legacy_decrypt(data: bytes, password: Optional[str]) -> Optional[bytes]: + if not password or len(data) <= 8: + return None + iv = data[:8] + ciphertext = data[8:] + key = _evp_bytes_to_key(password.encode(), iv, 24) + try: + decryptor = Cipher(TripleDES(key), modes.CBC(iv)).decryptor() + padded = decryptor.update(ciphertext) + decryptor.finalize() + pad = padded[-1] + if pad < 1 or pad > 8: + return None + return padded[:-pad] + except Exception: # noqa: BLE001 + return None + + +def _decrypt_private(encoded: str, password: Optional[str]) -> Optional[Any]: + try: + data = base64.b64decode(encoded) + except Exception: # noqa: BLE001 + return None + # Modern XCA: PKCS#8 EncryptedPrivateKeyInfo. + try: + return serialization.load_der_private_key( + data, password=password.encode() if password else None + ) + except Exception: # noqa: BLE001 + pass + # Pre-2.0 legacy: 8-byte IV + 3DES-CBC(traditional DER private key). + legacy = _legacy_decrypt(data, password) + if legacy: + try: + return serialization.load_der_private_key(legacy, password=None) + except Exception: # noqa: BLE001 + pass + return None + + +def _item_extra(row: sqlite3.Row, item_id: int) -> Dict[str, Any]: + return { + "xca_item_id": item_id, + "source": _PKI_SOURCE.get(int(row["source"] or 0), "unknown"), + "xca_date": _parse_date(row["date"]), + "comment": str(row["comment"] or ""), + } + + +def import_xca_database( + data: bytes, + filename: str = "", + password: Optional[str] = None, +) -> List[ParsedItem]: + with tempfile.NamedTemporaryFile(suffix=".xdb", delete=False) as handle: + handle.write(data) + path = handle.name + try: + connection = sqlite3.connect(path) + connection.row_factory = sqlite3.Row + try: + schema_row = connection.execute( + "SELECT value FROM settings WHERE key_='schema'" + ).fetchone() + if schema_row is None: + raise ValueError("not an XCA database (missing schema setting)") + schema = int(str(schema_row[0])) + if schema < 7: + raise ValueError(f"unsupported XCA database schema {schema}") + return _read_database(connection, filename, password) + finally: + connection.close() + except sqlite3.DatabaseError as exc: + raise ValueError("not a valid SQLite XCA database") from exc + finally: + try: + import os + + os.unlink(path) + except OSError: + pass + + +def _read_database( + connection: sqlite3.Connection, + filename: str, + password: Optional[str], +) -> List[ParsedItem]: + items: Dict[int, sqlite3.Row] = { + int(row["id"]): row + for row in connection.execute( + "SELECT id, name, type, source, date, comment FROM items WHERE del=0" + ) + } + if not items: + return [] + + # XCA's x509super table maps certificates/requests to key item ids. + key_refs: Dict[int, int] = {} + try: + for row in connection.execute("SELECT item, pkey FROM x509super"): + if row["pkey"] is not None: + key_refs[int(row["item"])] = int(row["pkey"]) + except sqlite3.DatabaseError: + pass + + result: List[ParsedItem] = [] + key_objects: Dict[int, Any] = {} + + # --- keys -------------------------------------------------------------- + public_rows = {int(row["item"]): row for row in connection.execute("SELECT item, type, len, \"public\" FROM public_keys")} + private_rows = {int(row["item"]): row for row in connection.execute("SELECT item, ownPass, private FROM private_keys")} + for item_id, row in items.items(): + if int(row["type"]) != 1 or item_id not in public_rows: + continue + public_row = public_rows[item_id] + try: + public_key = serialization.load_der_public_key(base64.b64decode(public_row["public"])) + except Exception: # noqa: BLE001 + continue + key = public_key + private_row = private_rows.get(item_id) + if private_row is not None: + own_pass = int(private_row["ownPass"] or 0) + # ptCommon (0) and ptPrivate (1) are both tried with the supplied + # database password; ptBogus/ptPin cannot be used here. + if own_pass in (0, 1): + decrypted = _decrypt_private(str(private_row["private"]), password) + if decrypted is not None and is_private_key(decrypted): + key = decrypted + item = ParsedItem( + kind="key", + name=str(row["name"] or "key"), + summary=f"{public_row['type']} {public_row['len']}", + obj=key, + is_private=is_private_key(key), + extra=_item_extra(row, item_id), + ) + item.extra["has_private"] = is_private_key(key) + result.append(item) + key_objects[item_id] = key + + # --- certificates ------------------------------------------------------ + cert_rows = {int(row["item"]): row for row in connection.execute("SELECT item, issuer, ca, cert FROM certs")} + authority_rows = {int(row["item"]): row for row in connection.execute("SELECT item, template, crlExpire, crlNo, crlDays FROM authority")} + try: + takey_rows = {int(row["item"]): row for row in connection.execute("SELECT item, value FROM takeys")} + except sqlite3.DatabaseError: + takey_rows = {} + revocations: Dict[int, List[Dict[str, Any]]] = {} + try: + for row in connection.execute( + "SELECT caId, serial, date, invaldate, crlNo, reasonBit FROM revocations" + ): + revocations.setdefault(int(row["caId"]), []).append( + { + "serial_hex": str(row["serial"] or ""), + "revoked_at": _parse_date(row["date"]), + "invalidity_date": _parse_date(row["invaldate"]), + "crl_number": int(row["crlNo"] or 0), + "reason_bit": int(row["reasonBit"] or 0), + } + ) + except sqlite3.DatabaseError: + pass + for item_id, row in items.items(): + if int(row["type"]) != 3 or item_id not in cert_rows: + continue + cert_row = cert_rows[item_id] + try: + cert = parse_certificate(base64.b64decode(cert_row["cert"])) + except Exception: # noqa: BLE001 + continue + item = ParsedItem( + kind="certificate", + name=str(row["name"] or "certificate"), + summary=cert.subject.rfc4514_string(), + obj=cert, + extra=_item_extra(row, item_id), + ) + item.extra["xca_issuer_id"] = int(cert_row["issuer"]) if cert_row["issuer"] is not None else None + item.extra["xca_key_id"] = key_refs.get(item_id) + item.extra["serial_hex"] = format(cert.serial_number, "x") + authority = authority_rows.get(item_id) + if authority is not None: + item.extra["authority"] = { + "crl_expire": _parse_date(authority["crlExpire"]), + "crl_number": int(authority["crlNo"] or 0), + "crl_days": int(authority["crlDays"] or 30), + } + if item_id in takey_rows: + try: + item.extra["tls_auth_key"] = base64.b64decode(takey_rows[item_id]["value"]) + except Exception: # noqa: BLE001 + pass + if item_id in revocations: + item.extra["revocations"] = revocations[item_id] + result.append(item) + + # --- requests ---------------------------------------------------------- + request_rows = {int(row["item"]): row for row in connection.execute("SELECT item, signed, request FROM requests")} + for item_id, row in items.items(): + if int(row["type"]) != 2 or item_id not in request_rows: + continue + request_row = request_rows[item_id] + try: + csr = parse_csr(base64.b64decode(request_row["request"])) + except Exception: # noqa: BLE001 + continue + item = ParsedItem( + kind="request", + name=str(row["name"] or "request"), + summary=csr.subject.rfc4514_string(), + obj=csr, + extra=_item_extra(row, item_id), + ) + item.extra["xca_key_id"] = key_refs.get(item_id) + item.extra["signed"] = bool(request_row["signed"]) + result.append(item) + + # --- CRLs -------------------------------------------------------------- + crl_rows = {int(row["item"]): row for row in connection.execute("SELECT item, issuer, crl FROM crls")} + for item_id, row in items.items(): + if int(row["type"]) != 4 or item_id not in crl_rows: + continue + crl_row = crl_rows[item_id] + try: + crl = parse_crl(base64.b64decode(crl_row["crl"])) + except Exception: # noqa: BLE001 + continue + item = ParsedItem( + kind="crl", + name=str(row["name"] or "crl"), + summary=crl.issuer.rfc4514_string(), + obj=crl, + extra=_item_extra(row, item_id), + ) + item.extra["xca_issuer_id"] = int(crl_row["issuer"]) if crl_row["issuer"] is not None else None + result.append(item) + + # --- templates --------------------------------------------------------- + template_rows = {int(row["item"]): row for row in connection.execute("SELECT item, version, template FROM templates")} + for item_id, row in items.items(): + if int(row["type"]) != 5 or item_id not in template_rows: + continue + template_row = template_rows[item_id] + try: + payload = base64.b64decode(template_row["template"]) + template = template_from_xca_payload(payload, int(template_row["version"] or 10)) + except Exception: # noqa: BLE001 + continue + template["name"] = str(row["name"] or template.get("name") or "template") + template["comment"] = str(row["comment"] or "") + item = ParsedItem( + kind="template", + name=str(row["name"] or "template"), + summary=str(row["comment"] or ""), + obj=template, + extra=_item_extra(row, item_id), + ) + result.append(item) + + return result + + +__all__ = ["import_xca_database"] diff --git a/server/packages/kca-core/tests/test_core.py b/server/packages/kca-core/tests/test_core.py new file mode 100644 index 0000000..8432a75 --- /dev/null +++ b/server/packages/kca-core/tests/test_core.py @@ -0,0 +1,302 @@ +"""Core domain tests: names, extensions, keys, certificates, CRLs and exports.""" +from __future__ import annotations + +import unittest +from datetime import datetime, timedelta, timezone + +from cryptography import x509 +from cryptography.hazmat.primitives import serialization + +from kca.core import certificates, crls, crypto, exports, imports, keys, names, requests, templates +from kca.core.extensions import ( + build_extensions, + default_extension_spec, + extension_specs, +) + + +def _name(cn: str = "Test CA") -> x509.Name: + return names.name_from_entries( + names.entries_from_json( + [ + {"name": "C", "value": "DE"}, + {"name": "O", "value": "KCA"}, + {"name": "CN", "value": cn}, + ] + ) + ) + + +def _ca_spec() -> dict: + spec = default_extension_spec() + spec["basic_constraints"] = {"critical": True, "ca": True, "path_length": None} + spec["key_usage"] = {"critical": True, "usages": ["key_cert_sign", "crl_sign"]} + spec["subject_key_identifier"] = {"critical": False} + spec["authority_key_identifier"] = {"critical": False} + return spec + + +class NamesTest(unittest.TestCase): + def test_order_and_duplicates_survive(self) -> None: + name = _name() + entries = names.entries_from_name(name) + self.assertEqual([e.name for e in entries], ["C", "O", "CN"]) + self.assertEqual(name.rfc4514_string(), "CN=Test CA,O=KCA,C=DE") + + def test_der_roundtrip(self) -> None: + name = _name() + self.assertEqual(names.name_from_der(names.name_to_der(name)), name) + + +class ExtensionTest(unittest.TestCase): + def test_roundtrip(self) -> None: + key = keys.generate_key("rsa", 2048) + spec = _ca_spec() + spec["subject_alt_name"] = { + "critical": False, + "entries": [ + {"type": "dns", "value": "example.com"}, + {"type": "ip", "value": "192.0.2.1"}, + {"type": "email", "value": "ca@example.com"}, + ], + } + exts = build_extensions(spec, public_key=key.public_key(), subject_name=_name()) + parsed = extension_specs(exts) + self.assertTrue(parsed["basic_constraints"]["ca"]) + self.assertEqual(parsed["basic_constraints"]["path_length"], None) + self.assertEqual(len(parsed["subject_alt_name"]["entries"]), 3) + self.assertIn("key_cert_sign", parsed["key_usage"]["usages"]) + + def test_raw_extension(self) -> None: + key = keys.generate_key("ec", curve="secp256r1") + spec = default_extension_spec() + spec["raw"] = [{"oid": "1.2.3.4", "critical": True, "der_hex": "0c03616263"}] + exts = build_extensions(spec, public_key=key.public_key(), subject_name=_name()) + self.assertEqual(exts[0].oid.dotted_string, "1.2.3.4") + self.assertTrue(exts[0].critical) + + +class KeyTest(unittest.TestCase): + def test_all_types(self) -> None: + for key_type, kwargs in [ + ("rsa", {"size": 2048}), + ("ec", {"curve": "secp256r1"}), + ("ed25519", {}), + ]: + key = keys.generate_key(key_type, **kwargs) + info = keys.key_info(key) + self.assertTrue(info.is_private) + self.assertEqual(info.key_type.upper() if key_type != "ed25519" else "ED25519", info.key_type) + loaded, private = keys.load_key_bytes(keys.private_pem(key)) + self.assertTrue(private) + self.assertEqual(keys.public_der(loaded), info.public_der) + + def test_encrypted_export_roundtrip(self) -> None: + key = keys.generate_key("rsa", 2048) + encrypted = keys.private_pem(key, password="secret") + loaded, private = keys.load_key_bytes(encrypted, password="secret") + self.assertTrue(private) + self.assertEqual(keys.public_der(loaded), keys.public_der(key)) + + def test_ssh_and_pvk(self) -> None: + key = keys.generate_key("rsa", 2048) + public = keys.ssh_public(key, "test") + loaded, private = keys.load_key_bytes(public.encode()) + self.assertFalse(private) + pvk = keys.pvk_encode_private(key) + loaded, private = keys.load_key_bytes(pvk) + self.assertTrue(private) + self.assertEqual(keys.public_der(loaded), keys.public_der(key)) + + +class CertificateTest(unittest.TestCase): + def test_issue_and_verify(self) -> None: + ca_key = keys.generate_key("rsa", 2048) + now = datetime.now(timezone.utc) + ca = certificates.build_self_signed( + subject=_name("Root CA"), + private_key=ca_key, + serial=1, + not_before=now, + not_after=now + timedelta(days=3650), + extension_spec=_ca_spec(), + ) + self.assertTrue(certificates.is_self_signed(ca)) + self.assertTrue(certificates.certificate_info(ca).is_ca) + + leaf_key = keys.generate_key("ec", curve="secp256r1") + spec = default_extension_spec() + spec["basic_constraints"] = {"critical": True, "ca": False, "path_length": None} + spec["key_usage"] = {"critical": True, "usages": ["digital_signature", "key_encipherment"]} + spec["extended_key_usage"] = {"critical": False, "usages": ["serverAuth"]} + spec["subject_alt_name"] = {"critical": False, "entries": [{"type": "dns", "value": "example.com"}]} + leaf = certificates.build_signed( + subject=_name("example.com"), + public_key=leaf_key.public_key(), + issuer_cert=ca, + issuer_private_key=ca_key, + serial=2, + not_before=now, + not_after=now + timedelta(days=365), + extension_spec=spec, + ) + self.assertTrue(certificates.verify_directly_issued_by(leaf, ca)) + self.assertFalse(certificates.verify_directly_issued_by(leaf, leaf)) + self.assertIn("SSL server", certificates.purposes(leaf)) + parsed = certificates.parse_certificate(certificates.certificate_der(leaf)) + self.assertEqual(parsed.serial_number, 2) + self.assertEqual(certificates.find_issuer(parsed, [ca]), ca) + self.assertEqual(certificates.validation_errors(parsed, [parsed, ca]), []) + + def test_renew_spec(self) -> None: + key = keys.generate_key("rsa", 2048) + now = datetime.now(timezone.utc) + cert = certificates.build_self_signed( + subject=_name("Renew Me"), + private_key=key, + serial=3, + not_before=now, + not_after=now + timedelta(days=30), + extension_spec=_ca_spec(), + ) + spec = certificates.certificate_spec(cert) + self.assertEqual(spec["serial_hex"], "3") + self.assertEqual(spec["subject"][-1]["name"], "CN") + + def test_index_and_config(self) -> None: + key = keys.generate_key("rsa", 2048) + now = datetime.now(timezone.utc) + cert = certificates.build_self_signed( + subject=_name("Index"), + private_key=key, + serial=4, + not_before=now, + not_after=now + timedelta(days=30), + extension_spec=_ca_spec(), + ) + self.assertIn("V\t", certificates.index_entry(cert)) + self.assertIn("basicConstraints", certificates.openssl_config(cert)) + self.assertIn("VCALENDAR", certificates.ics_calendar([certificates.ics_event(cert, "Index")])) + + +class RequestTest(unittest.TestCase): + def test_csr_roundtrip(self) -> None: + key = keys.generate_key("rsa", 2048) + csr = requests.build_csr( + subject=_name("CSR"), + private_key=key, + extension_spec=_ca_spec(), + attributes={requests.CHALLENGE_PASSWORD_OID: "secret"}, + ) + self.assertTrue(requests.verify_csr(csr)) + loaded = requests.parse_csr(requests.csr_der(csr)) + self.assertEqual(requests.request_info(loaded).subject, csr.subject) + self.assertEqual(requests.attribute_values(loaded)[requests.CHALLENGE_PASSWORD_OID], "secret") + + +class CrlTest(unittest.TestCase): + def test_build_and_parse(self) -> None: + key = keys.generate_key("rsa", 2048) + now = datetime.now(timezone.utc) + ca = certificates.build_self_signed( + subject=_name("CRL CA"), + private_key=key, + serial=5, + not_before=now, + not_after=now + timedelta(days=3650), + extension_spec=_ca_spec(), + ) + crl = crls.build_crl( + issuer_cert=ca, + issuer_private_key=key, + revocations=[ + { + "serial_hex": "10", + "revoked_at": now, + "reason": "keyCompromise", + "invalidity_date": now - timedelta(days=1), + } + ], + last_update=now, + next_update=now + timedelta(days=30), + crl_number=7, + ) + info = crls.crl_info(crl) + self.assertEqual(info.crl_number, 7) + self.assertEqual(info.revoked_count, 1) + self.assertTrue(crls.verify_crl(crl, ca)) + parsed = crls.parse_crl(crls.crl_der(crl)) + rows = crls.crl_revocations(parsed) + self.assertEqual(rows[0]["serial_hex"], "10") + self.assertEqual(rows[0]["reason"], "keyCompromise") + + +class TemplateTest(unittest.TestCase): + def test_xca_roundtrip(self) -> None: + template = templates.builtin_templates()[0].as_dict() + data = templates.template_to_xca_data(template) + parsed = templates.template_from_xca_data(data) + self.assertEqual(parsed["name"], "CA") + self.assertTrue(parsed["settings"]["extensions"]["basic_constraints"]["ca"]) + + def test_apply(self) -> None: + template = templates.builtin_templates()[1].as_dict() + merged = templates.apply_template(template, {"subject": [], "extensions": {}}) + self.assertIn("serverAuth", merged["extensions"]["extended_key_usage"]["usages"]) + + +class ExportImportTest(unittest.TestCase): + def setUp(self) -> None: + self.key = keys.generate_key("rsa", 2048) + now = datetime.now(timezone.utc) + self.cert = certificates.build_self_signed( + subject=_name("Export"), + private_key=self.key, + serial=6, + not_before=now, + not_after=now + timedelta(days=30), + extension_spec=_ca_spec(), + ) + + def test_certificate_exports(self) -> None: + for fmt, prefix in [ + ("pem", b"-----BEGIN CERTIFICATE"), + ("der", b"0"), + ("pkcs7", b"0"), + ("pkcs12", b"0"), + ]: + result = exports.export_certificate( + self.cert, fmt, name="export", private_key=self.key, password="secret" + ) + self.assertTrue(result.data.startswith(prefix), fmt) + pem = exports.export_certificate(self.cert, "pem", name="export") + items = imports.parse_file(pem.data, "export.crt") + self.assertEqual(items[0].kind, "certificate") + + def test_key_exports(self) -> None: + for fmt in ("pem-public", "pem-private", "pkcs8", "ssh-public", "jwk-public", "pvk"): + result = exports.export_key(self.key, fmt, name="export") + self.assertTrue(result.data) + encrypted = exports.export_key(self.key, "pkcs8-encrypted", name="export", password="secret") + loaded, private = keys.load_key_bytes(encrypted.data, password="secret") + self.assertTrue(private) + + def test_pkcs12_contains_chain(self) -> None: + result = exports.export_certificate( + self.cert, "pkcs12-chain", name="export", chain=[self.cert], private_key=self.key, password="secret" + ) + items = imports.parse_file(result.data, "export.pfx", "secret") + self.assertGreaterEqual(len(items), 2) + + +class CryptoTest(unittest.TestCase): + def test_envelope(self) -> None: + master = crypto.generate_master_key() + blob = crypto.encrypt_private_key(b"secret", "key-id", crypto.decode_master_key(master)) + self.assertEqual(crypto.decrypt_private_key(blob, "key-id", crypto.decode_master_key(master)), b"secret") + with self.assertRaises(crypto.DecryptionError): + crypto.decrypt_private_key(blob, "other-id", crypto.decode_master_key(master)) + + +if __name__ == "__main__": + unittest.main() diff --git a/server/packages/kca-core/tests/test_xca_db.py b/server/packages/kca-core/tests/test_xca_db.py new file mode 100644 index 0000000..37165a3 --- /dev/null +++ b/server/packages/kca-core/tests/test_xca_db.py @@ -0,0 +1,135 @@ +"""XCA .xdb import tests using a synthetic schema-compatible database.""" +from __future__ import annotations + +import base64 +import sqlite3 +import struct +import tempfile +import unittest +from datetime import datetime, timedelta, timezone + +from cryptography.hazmat.primitives import serialization + +from kca.core import certificates, crls, keys, names, requests, templates +from kca.core.extensions import build_extensions, default_extension_spec +from kca.core.xca_db import import_xca_database + + +def _name(cn: str) -> object: + return names.name_from_entries(names.entries_from_json([{"name": "CN", "value": cn}])) + + +class XcaDbTest(unittest.TestCase): + def test_import_synthetic_database(self) -> None: + key = keys.generate_key("rsa", 2048) + now = datetime.now(timezone.utc) + spec = default_extension_spec() + spec["basic_constraints"] = {"critical": True, "ca": True, "path_length": None} + exts = build_extensions(spec, public_key=key.public_key(), subject_name=_name("Synthetic CA")) + cert = certificates.build_certificate( + subject=_name("Synthetic CA"), + public_key=key.public_key(), + issuer_cert=None, + issuer_private_key=key, + serial=42, + not_before=now, + not_after=now + timedelta(days=365), + extensions=exts, + ) + csr = requests.build_csr(subject=_name("Synthetic CSR"), private_key=key, extension_spec=spec) + crl = crls.build_crl( + issuer_cert=cert, + issuer_private_key=key, + revocations=[{"serial_hex": "2a", "revoked_at": now, "reason": "keyCompromise"}], + last_update=now, + next_update=now + timedelta(days=30), + crl_number=3, + ) + tmpl = templates.builtin_templates()[0].as_dict() + tmpl_pem = templates.template_to_xca_data(tmpl) + body = tmpl_pem.decode().split("-----BEGIN XCA TEMPLATE-----")[1].split("-----END XCA TEMPLATE-----")[0] + payload = base64.b64decode("".join(body.split())) + tmpl_data = payload[8:] + + date = now.strftime("%Y%m%d%H%M%SZ") + with tempfile.NamedTemporaryFile(suffix=".xdb", delete=False) as handle: + path = handle.name + try: + db = sqlite3.connect(path) + db.executescript( + """ + CREATE TABLE settings (key_ CHAR(20) UNIQUE, value TEXT); + CREATE TABLE items (id INTEGER PRIMARY KEY, name VARCHAR(128), type INTEGER, source INTEGER, date CHAR(15), comment VARCHAR(2048), stamp INTEGER DEFAULT 0, del SMALLINT DEFAULT 0); + CREATE TABLE public_keys (item INTEGER, type CHAR(4), hash INTEGER, len INTEGER, "public" TEXT); + CREATE TABLE private_keys (item INTEGER, ownPass INTEGER, private TEXT); + CREATE TABLE x509super (item INTEGER, subj_hash INTEGER, pkey INTEGER, key_hash INTEGER); + CREATE TABLE requests (item INTEGER, hash INTEGER, signed INTEGER, request TEXT); + CREATE TABLE certs (item INTEGER, hash INTEGER, iss_hash INTEGER, serial VARCHAR(64), issuer INTEGER, ca INTEGER, cert TEXT); + CREATE TABLE authority (item INTEGER, template INTEGER, crlExpire CHAR(15), crlNo INTEGER, crlDays INTEGER); + CREATE TABLE crls (item INTEGER, hash INTEGER, num INTEGER, iss_hash INTEGER, issuer INTEGER, crl TEXT); + CREATE TABLE revocations (caId INTEGER, serial VARCHAR(64), date CHAR(15), invaldate CHAR(15), crlNo INTEGER, reasonBit INTEGER); + CREATE TABLE templates (item INTEGER, version INTEGER, template TEXT); + CREATE TABLE takeys (item INTEGER UNIQUE, value TEXT); + """ + ) + db.execute("INSERT INTO settings VALUES ('schema', '8')") + db.execute("INSERT INTO items VALUES (1, 'Synthetic key', 1, 2, ?, '', 0, 0)", (date,)) + db.execute("INSERT INTO items VALUES (2, 'Synthetic CA', 3, 2, ?, '', 0, 0)", (date,)) + db.execute("INSERT INTO items VALUES (3, 'Synthetic CSR', 2, 2, ?, '', 0, 0)", (date,)) + db.execute("INSERT INTO items VALUES (4, 'Synthetic CRL', 4, 2, ?, '', 0, 0)", (date,)) + db.execute("INSERT INTO items VALUES (5, 'Synthetic template', 5, 2, ?, '', 0, 0)", (date,)) + encrypted = key.private_bytes( + serialization.Encoding.DER, + serialization.PrivateFormat.PKCS8, + serialization.BestAvailableEncryption(b"dbpass"), + ) + db.execute( + "INSERT INTO public_keys VALUES (1, 'RSA', 0, 2048, ?)", + (base64.b64encode(keys.public_der(key)).decode(),), + ) + db.execute( + "INSERT INTO private_keys VALUES (1, 0, ?)", + (base64.b64encode(encrypted).decode(),), + ) + db.execute("INSERT INTO x509super VALUES (2, 0, 1, 0)") + db.execute("INSERT INTO x509super VALUES (3, 0, 1, 0)") + db.execute( + "INSERT INTO certs VALUES (2, 0, 0, '2a', NULL, 1, ?)", + (base64.b64encode(certificates.certificate_der(cert)).decode(),), + ) + db.execute("INSERT INTO authority VALUES (2, NULL, NULL, 0, 30)") + db.execute( + "INSERT INTO revocations VALUES (2, '2a', ?, NULL, 0, 1)", + (date,), + ) + db.execute( + "INSERT INTO requests VALUES (3, 0, 0, ?)", + (base64.b64encode(requests.csr_der(csr)).decode(),), + ) + db.execute( + "INSERT INTO crls VALUES (4, 0, 1, 0, 2, ?)", + (base64.b64encode(crls.crl_der(crl)).decode(),), + ) + db.execute( + "INSERT INTO templates VALUES (5, 10, ?)", + (base64.b64encode(tmpl_data).decode(),), + ) + db.commit() + db.close() + + with open(path, "rb") as handle: + items = import_xca_database(handle.read(), "synthetic.xdb", "dbpass") + finally: + import os + + os.unlink(path) + kinds = [item.kind for item in items] + self.assertEqual(kinds, ["key", "certificate", "request", "crl", "template"]) + self.assertTrue(items[0].is_private) + self.assertEqual(items[1].name, "Synthetic CA") + self.assertEqual(items[3].name, "Synthetic CRL") + self.assertEqual(items[4].obj["name"], "Synthetic template") + + +if __name__ == "__main__": + unittest.main() diff --git a/server/pyproject.toml b/server/pyproject.toml new file mode 100644 index 0000000..2232db2 --- /dev/null +++ b/server/pyproject.toml @@ -0,0 +1,57 @@ +# KCA backend workspace. +# +# The pure domain logic lives in packages/kca-core (no kaya, no database); +# the deployable application lives in packages/kca-app. This file is the uv +# workspace root and also holds the tooling configuration read from server/ +# (uv, aerich, mypy). + +[tool.uv] +index-strategy = "unsafe-best-match" + +[[tool.uv.index]] +name = "gitea" +url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/simple" +default = true + +[[tool.uv.index]] +name = "pypi" +url = "https://pypi.org/simple" + +[tool.uv.workspace] +members = ["packages/*"] + +[tool.uv.sources] +kca-core = { workspace = true } + +[tool.aerich] +tortoise_orm = "kca.aerich_config.TORTOISE_ORM" +location = "./migrations" + +[tool.mypy] +python_version = "3.12" +ignore_missing_imports = true +plugins = [] + +[[tool.mypy.overrides]] +module = "kca.models" +disable_error_code = ["attr-defined", "assignment", "var-annotated"] + +[[tool.mypy.overrides]] +module = "kca.service" +disable_error_code = ["attr-defined", "assignment", "index", "arg-type"] + +[[tool.mypy.overrides]] +module = "kca.core.imports" +disable_error_code = ["assignment", "attr-defined"] + +[[tool.mypy.overrides]] +module = "kca.core.exports" +disable_error_code = ["arg-type"] + +[[tool.mypy.overrides]] +module = "kca.routes.*" +disable_error_code = ["attr-defined", "assignment", "index", "arg-type"] + +[[tool.mypy.overrides]] +module = "kca.core.*" +disable_error_code = ["arg-type", "assignment", "attr-defined", "index", "return-value", "var-annotated"] diff --git a/server/tests/__init__.py b/server/tests/__init__.py new file mode 100644 index 0000000..de28dc0 --- /dev/null +++ b/server/tests/__init__.py @@ -0,0 +1,12 @@ +"""Test package init: set environment overrides before importing kca.app.""" +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "sqlite://:memory:") +os.environ.setdefault("OIDC_0_PROVIDER", "default") +os.environ.setdefault("OIDC_0_ISSUER", "http://localhost:8180/kca") +os.environ.setdefault("OIDC_0_CLIENT_ID", "kca") +os.environ.setdefault("OIDC_0_REDIRECT_URI", "http://localhost:8080/auth/default/callback") +os.environ.setdefault("KCA_MASTER_KEY", "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=") +os.environ.pop("REDIS_URL", None) diff --git a/server/tests/helpers/__init__.py b/server/tests/helpers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/tests/helpers/api.py b/server/tests/helpers/api.py new file mode 100644 index 0000000..f6460d8 --- /dev/null +++ b/server/tests/helpers/api.py @@ -0,0 +1,17 @@ +"""In-process HTTP client for the app.""" +from __future__ import annotations + +import contextlib +from typing import AsyncIterator + +from httpx import ASGITransport, AsyncClient + +from kca.app import app, tortoise_mixin + + +@contextlib.asynccontextmanager +async def api_client() -> AsyncIterator[AsyncClient]: + await tortoise_mixin.ensure_context() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://127.0.0.1") as client: + yield client diff --git a/server/tests/helpers/asynctest.py b/server/tests/helpers/asynctest.py new file mode 100644 index 0000000..98f53d5 --- /dev/null +++ b/server/tests/helpers/asynctest.py @@ -0,0 +1,23 @@ +"""``async_test`` that closes the app's per-loop Tortoise context.""" +from __future__ import annotations + +import asyncio +from functools import wraps +from typing import Any, Callable, Coroutine + +from kca.app import tortoise_mixin + + +def async_test(coro: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., None]: + @wraps(coro) + def wrapper(*args: Any, **kwargs: Any) -> None: + async def run() -> None: + try: + await coro(*args, **kwargs) + finally: + await tortoise_mixin.aclose() + + with asyncio.Runner() as runner: + runner.run(run()) + + return wrapper diff --git a/server/tests/helpers/oidc.py b/server/tests/helpers/oidc.py new file mode 100644 index 0000000..3f51617 --- /dev/null +++ b/server/tests/helpers/oidc.py @@ -0,0 +1,35 @@ +"""Fake the OIDC-authenticated user during tests.""" +from __future__ import annotations + +import contextlib +import unittest.mock as _mock +from typing import Any, Iterator, Optional + +from kaya.oidc import OIDCUser + +from kca.app import oidc + +_PROVIDER = next(iter(oidc.providers)) + + +def make_user(sub: str, name: Optional[str] = None, **claims: Any) -> OIDCUser: + data: dict = {"sub": sub, "preferred_username": name or sub} + data.update(claims) + return OIDCUser(data) + + +@contextlib.contextmanager +def oidc_user( + sub: str, + name: Optional[str] = None, + **claims: Any, +) -> Iterator[OIDCUser]: + data: dict = {"sub": sub, "preferred_username": name or sub} + data.update(claims) + user = OIDCUser(data) + patcher = _mock.patch.object(oidc, "get_users", return_value={_PROVIDER: user}) + patcher.start() + try: + yield user + finally: + patcher.stop() diff --git a/server/tests/test_routes_extra.py b/server/tests/test_routes_extra.py new file mode 100644 index 0000000..c43ece9 --- /dev/null +++ b/server/tests/test_routes_extra.py @@ -0,0 +1,195 @@ +"""Additional route-level tests: CSRs, tokens, import/export and templates.""" +from __future__ import annotations + +import base64 +import unittest +from datetime import datetime, timedelta, timezone + +from tests.helpers.api import api_client +from tests.helpers.asynctest import async_test +from tests.helpers.oidc import oidc_user + + +def _subject(cn: str) -> list[dict]: + return [{"name": "C", "value": "DE"}, {"name": "O", "value": "KCA"}, {"name": "CN", "value": cn}] + + +def _extensions(ca: bool = False) -> dict: + return { + "basic_constraints": {"critical": True, "ca": ca, "path_length": None}, + "key_usage": {"critical": True, "usages": ["key_cert_sign", "crl_sign"] if ca else ["digital_signature"]}, + "subject_key_identifier": {"critical": False}, + "authority_key_identifier": {"critical": False}, + } + + +async def _vault(client) -> str: + response = await client.post("/api/vaults", json={"name": "Test"}) + assert response.status_code == 201, response.text + return response.json()["id"] + + +async def _key(client, vault_id: str, name: str = "key") -> dict: + response = await client.post( + f"/api/vaults/{vault_id}/keys", json={"name": name, "key_type": "rsa", "size": 2048} + ) + assert response.status_code == 201, response.text + return response.json() + + +async def _cert(client, vault_id: str, key_id: str, name: str, ca: bool = False, signer_id: str | None = None) -> dict: + now = datetime.now(timezone.utc) + response = await client.post( + f"/api/vaults/{vault_id}/certificates", + json={ + "name": name, + "key_id": key_id, + "signer_id": signer_id, + "subject": _subject(name), + "not_before": now.isoformat(), + "not_after": (now + timedelta(days=365)).isoformat(), + "extensions": _extensions(ca), + }, + ) + assert response.status_code == 201, response.text + return response.json() + + +class RouteExtraTests(unittest.TestCase): + @async_test + async def test_csr_creation_and_signing(self) -> None: + async with api_client() as client: + with oidc_user("alice"): + vault_id = await _vault(client) + ca_key = await _key(client, vault_id, "ca key") + ca = await _cert(client, vault_id, ca_key["id"], "Root CA", ca=True) + response = await client.post( + f"/api/vaults/{vault_id}/requests", + json={ + "name": "web request", + "subject": _subject("web.example.com"), + "new_key": {"key_type": "ec", "curve": "secp256r1", "name": "web key"}, + "extensions": _extensions(False), + }, + ) + assert response.status_code == 201, response.text + request = response.json() + assert request["signature_valid"] is True + response = await client.post( + f"/api/vaults/{vault_id}/certificates", + json={ + "name": "web cert", + "request_id": request["id"], + "signer_id": ca["id"], + "not_before": datetime.now(timezone.utc).isoformat(), + "not_after": (datetime.now(timezone.utc) + timedelta(days=30)).isoformat(), + }, + ) + assert response.status_code == 201, response.text + cert = response.json() + assert cert["issuer_id"] == ca["id"] + response = await client.get(f"/api/vaults/{vault_id}/requests/{request['id']}") + assert response.json()["signed"] is True + + @async_test + async def test_token_authentication(self) -> None: + async with api_client() as client: + with oidc_user("alice"): + response = await client.post("/api/tokens", json={"name": "script"}) + assert response.status_code == 201, response.text + secret = response.json()["token"] + assert secret.startswith("kca_") + # Session is no longer used: the bearer token authenticates. + response = await client.get("/api/vaults", headers={"authorization": f"Bearer {secret}"}) + assert response.status_code == 200 + response = await client.post("/api/tokens", headers={"authorization": f"Bearer {secret}"}, json={"name": "x"}) + assert response.status_code == 401 + + @async_test + async def test_import_preview_and_commit(self) -> None: + async with api_client() as client: + with oidc_user("alice"): + vault_id = await _vault(client) + key = await _key(client, vault_id, "import key") + cert = await _cert(client, vault_id, key["id"], "Import Me", ca=True) + export = await client.post( + f"/api/vaults/{vault_id}/certificates/{cert['id']}/export", json={"format": "pem"} + ) + pem = base64.b64decode(export.json()["content_base64"]) + response = await client.post( + f"/api/vaults/{vault_id}/import", + json={"filename": "test.crt", "content_base64": base64.b64encode(pem).decode(), "dry_run": True}, + ) + assert response.status_code == 200, response.text + assert response.json()["items"][0]["duplicate"] is True + response = await client.post( + f"/api/vaults/{vault_id}/import", + json={"filename": "test.crt", "content_base64": base64.b64encode(pem).decode()}, + ) + assert response.status_code == 200 + assert len(response.json()["skipped"]) == 1 + + @async_test + async def test_settings_and_templates(self) -> None: + async with api_client() as client: + with oidc_user("alice"): + vault_id = await _vault(client) + response = await client.get(f"/api/vaults/{vault_id}/settings") + assert response.status_code == 200 + version = response.json()["version"] + response = await client.patch( + f"/api/vaults/{vault_id}/settings", + json={"settings": {"default_hash": "sha384", "serial_len_bits": 80}, "version": version}, + ) + assert response.status_code == 200, response.text + assert response.json()["settings"]["default_hash"] == "sha384" + response = await client.get(f"/api/vaults/{vault_id}/templates") + templates = response.json()["results"] + assert any(t["id"] == "builtin:ca" for t in templates) + response = await client.post( + f"/api/vaults/{vault_id}/templates/builtin:ca/apply", + json={"target": {"subject": [], "extensions": {}}}, + ) + assert response.status_code == 200 + assert response.json()["extensions"]["basic_constraints"]["ca"] is True + + @async_test + async def test_revocation_and_crl(self) -> None: + async with api_client() as client: + with oidc_user("alice"): + vault_id = await _vault(client) + ca_key = await _key(client, vault_id, "ca") + ca = await _cert(client, vault_id, ca_key["id"], "Root", ca=True) + leaf_key = await _key(client, vault_id, "leaf") + leaf = await _cert( + client, vault_id, leaf_key["id"], "Leaf", signer_id=ca["id"] + ) + response = await client.post( + f"/api/vaults/{vault_id}/certificates/{leaf['id']}/revoke", + json={"reason": "keyCompromise"}, + ) + assert response.status_code == 200, response.text + response = await client.get( + f"/api/vaults/{vault_id}/certificates/{leaf['id']}" + ) + assert response.json()["revoked"] is True + response = await client.post( + f"/api/vaults/{vault_id}/certificates/{ca['id']}/crl", + json={"days": 30}, + ) + assert response.status_code == 201, response.text + crl = response.json() + assert crl["revoked_count"] == 1 + assert crl["crl_number"] == 1 + + @async_test + async def test_key_delete_unlinks(self) -> None: + async with api_client() as client: + with oidc_user("alice"): + vault_id = await _vault(client) + key = await _key(client, vault_id) + cert = await _cert(client, vault_id, key["id"], "Unlink") + response = await client.delete(f"/api/vaults/{vault_id}/keys/{key['id']}") + assert response.status_code == 204 + response = await client.get(f"/api/vaults/{vault_id}/certificates/{cert['id']}") + assert response.json()["key_id"] is None diff --git a/server/tests/test_smoke.py b/server/tests/test_smoke.py new file mode 100644 index 0000000..43d7207 --- /dev/null +++ b/server/tests/test_smoke.py @@ -0,0 +1,126 @@ +"""End-to-end smoke tests for the core PKI workflow.""" +from __future__ import annotations + +import base64 +import unittest +from datetime import datetime, timedelta, timezone + +from tests.helpers.api import api_client +from tests.helpers.asynctest import async_test +from tests.helpers.oidc import oidc_user + + +def _subject(cn: str = "Test CA") -> list[dict]: + return [ + {"name": "C", "value": "DE"}, + {"name": "O", "value": "KCA"}, + {"name": "CN", "value": cn}, + ] + + +def _extensions() -> dict: + return { + "basic_constraints": {"critical": True, "ca": True, "path_length": None}, + "key_usage": {"critical": True, "usages": ["key_cert_sign", "crl_sign"]}, + "subject_key_identifier": {"critical": False}, + "authority_key_identifier": {"critical": False}, + } + + +class SmokeTests(unittest.TestCase): + @async_test + async def test_health_and_auth(self) -> None: + async with api_client() as client: + response = await client.get("/api/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + response = await client.get("/api/me") + assert response.status_code == 401 + + @async_test + async def test_core_workflow(self) -> None: + now = datetime.now(timezone.utc) + async with api_client() as client: + with oidc_user("alice", "Alice"): + response = await client.get("/api/me") + assert response.status_code == 200 + assert response.json()["display_name"] == "Alice" + + response = await client.post("/api/vaults", json={"name": "Test Vault"}) + assert response.status_code == 201, response.text + vault = response.json() + vault_id = vault["id"] + + response = await client.post( + f"/api/vaults/{vault_id}/keys", + json={"name": "CA key", "key_type": "rsa", "size": 2048}, + ) + assert response.status_code == 201, response.text + key = response.json() + assert key["has_private"] is True + + response = await client.post( + f"/api/vaults/{vault_id}/certificates", + json={ + "name": "Test CA", + "comment": "", + "key_id": key["id"], + "subject": _subject(), + "not_before": now.isoformat(), + "not_after": (now + timedelta(days=3650)).isoformat(), + "signature_hash": "sha256", + "extensions": _extensions(), + }, + ) + assert response.status_code == 201, response.text + cert = response.json() + assert cert["is_ca"] is True + assert cert["subject_rfc4514"].startswith("CN=Test CA") + assert cert["validation_errors"] == [] + + response = await client.get(f"/api/vaults/{vault_id}/certificates") + assert response.status_code == 200 + assert len(response.json()["results"]) == 1 + + response = await client.get(f"/api/vaults/{vault_id}/search?q=test") + assert response.status_code == 200 + assert len(response.json()["results"]) >= 1 + + response = await client.post( + f"/api/vaults/{vault_id}/certificates/{cert['id']}/crl", + json={"days": 30}, + ) + assert response.status_code == 201, response.text + crl = response.json() + assert crl["crl_number"] == 1 + + response = await client.post( + f"/api/vaults/{vault_id}/certificates/{cert['id']}/export", + json={"format": "pem"}, + ) + assert response.status_code == 200, response.text + payload = response.json() + assert base64.b64decode(payload["content_base64"]).startswith(b"-----BEGIN CERTIFICATE") + + response = await client.post( + f"/api/vaults/{vault_id}/import", + json={ + "filename": "ca.crt", + "content_base64": payload["content_base64"], + "dry_run": True, + }, + ) + assert response.status_code == 200, response.text + assert response.json()["items"][0]["duplicate"] is True + + @async_test + async def test_ownership_isolation(self) -> None: + async with api_client() as client: + with oidc_user("alice", "Alice"): + response = await client.post("/api/vaults", json={"name": "Alice Vault"}) + vault_id = response.json()["id"] + with oidc_user("bob", "Bob"): + response = await client.get(f"/api/vaults/{vault_id}") + assert response.status_code == 404 + response = await client.get("/api/vaults") + assert response.json()["results"] == [] diff --git a/server/uv.lock b/server/uv.lock new file mode 100644 index 0000000..efae735 --- /dev/null +++ b/server/uv.lock @@ -0,0 +1,1949 @@ +version = 1 +revision = 5 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", + "python_full_version < '3.11'", +] + +[manifest] +members = [ + "kca-app", + "kca-core", +] + +[[package]] +name = "aerich" +version = "0.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "asyncclick", version = "8.3.0.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "asyncclick", version = "8.4.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "dictdiffer" }, + { name = "tortoise-orm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/a4/928e971cfdbff75cae335898b3a27643fc2564bc28e23efa182a8843e78c/aerich-0.10.1.tar.gz", hash = "sha256:d090b486de5f632a8c8e349e9227d076b26ba6ffabb522732bb681a9bc2e81e5", size = 91029, upload-time = "2026-08-24T17:33:12.989Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/35/e55ddee44dbcee573b03bc8407e1fb440128a89fa0547828f40d75c075a4/aerich-0.10.1-py3-none-any.whl", hash = "sha256:d8c6b67df6c701f6aa9775e3393d9458d119d2e12411e140a305a586e22b88a0", size = 49678, upload-time = "2026-08-24T17:33:11.973Z" }, +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/d5/5dc1ee8a80c3b20a0b63c61b43ba6bc53517faecd1def47bcd752d22268e/ast_serialize-0.13.1.tar.gz", hash = "sha256:401cc84e8888d4d76f2c8d296fa006ed1a5ff993b6aff90dd777c03973efd025", size = 955137, upload-time = "2026-10-17T00:04:17.183Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/a8/fbd136b9f70c59e75f59a8e974c84102976f1201828b4f886480ba956936/ast_serialize-0.13.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3e129d44b0cffbea24a7f40117ce49e30993c3d119e584d4817ebd6f87246456", size = 897306, upload-time = "2026-10-17T00:02:52.479Z" }, + { url = "https://files.pythonhosted.org/packages/50/3f/2ad51e9823a790e7a70fd781e9595ab3abeace27f3aefcaa1939457efd72/ast_serialize-0.13.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:523d45bab16ddaf1399c04c4fe81bbf180f81e33c2f7bcbe76be56b0f7311ab4", size = 1236082, upload-time = "2026-10-17T00:02:54.82Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5c/8e959db5cc2237f9d4c560a8e2253c98f9746883fb4774651e4cda458901/ast_serialize-0.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:638333fb8ae72c62753f9e794fda20290883ddc65b0d85e26e08f8bb227567ad", size = 1221242, upload-time = "2026-10-17T00:02:56.395Z" }, + { url = "https://files.pythonhosted.org/packages/d6/05/ab1c56c3b7adcac88764bba609502d6b4989e449b2dd60eb478e1d20e369/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc34c670747d85d4232834665e247009413eaebe47c69eaf60a5c439aaecaed9", size = 1282105, upload-time = "2026-10-17T00:02:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f4/41981fcd2ddffe0760e5326be9dcbd2eda9478a8430a637e5123bd8fcb26/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8c8ebd419811c435812d242fb2a173e57dfede198c9ac9a0a584666ebc9b9331", size = 1287905, upload-time = "2026-10-17T00:03:00.068Z" }, + { url = "https://files.pythonhosted.org/packages/6a/43/b783addc5681153707121991b7b47892f7e9c8ba58a0d03b71a3b023ba47/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8899c038b1c15ae8b98e379057fb3ef398ceb0b21d1737a8156dd5a0923b7e51", size = 1541806, upload-time = "2026-10-17T00:03:01.503Z" }, + { url = "https://files.pythonhosted.org/packages/47/10/969db095038ee15270f65ad20d1129c5e4da605bf7e93b70ae640f1c8a9f/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e14c4de98f965647a74e6eaf37e20207124e45451ccd36e84a6f6ebab45e0372", size = 1309946, upload-time = "2026-10-17T00:03:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/06/dd/7c623d2fb0b868948952d1787e629a43b866f7d4caefd5445e20aefacec4/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3e8a3eff69af640db985360fef35073fb46956dfc3e5c614cf00920d2746dd", size = 1302773, upload-time = "2026-10-17T00:03:04.378Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c2/4d49469110a19a44fc6269f4fd59679bcf84df068f185f89b73bee4da19b/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:75d7750fe98f6f35a96c43aba8d98a2e623e22c778a0c17ab9ede92eb10f5d82", size = 1299579, upload-time = "2026-10-17T00:03:05.88Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9a/2d381f1f262dda15affc8ad643fba1619eb07e7d9f8bd904deffef183006/ast_serialize-0.13.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:43e8ddd63e80fc280c2f790157386f27f9b9f6ccb9c4f8e803b863cbfb085769", size = 1355969, upload-time = "2026-10-17T00:03:07.321Z" }, + { url = "https://files.pythonhosted.org/packages/9d/9c/2266794e6c25fa9ce51ce04a9bf4b7e330e3ac9351ba3fa14e487678acdb/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9221e09496fb942901993c1812ee87950cc0ecbb514c7c0f1f5fa6d78f701ed8", size = 1460227, upload-time = "2026-10-17T00:03:08.733Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d6/14deb28e5d8f328f626a0778ea2cf98fe908f2bbcccd804cb6f449ee1782/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:7a5c38bdb93669c596f94fb7204dd6ebe4fadb19058ac9a2faf2948c2a175f96", size = 1562691, upload-time = "2026-10-17T00:03:10.25Z" }, + { url = "https://files.pythonhosted.org/packages/f5/19/7a28c0e5ba54f0d3e054f847a2a549b748cb179ab433321a8dc1b39e398d/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:acf4e9f055d75058ab01536da330648746f2f82b7697a85230547f2d5ebe895c", size = 1558669, upload-time = "2026-10-17T00:03:11.799Z" }, + { url = "https://files.pythonhosted.org/packages/18/9a/f9e2690eab58217f1d395a964a78efa7eaf813a1e38be8dfd9a8d45d0840/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87427913c325adc811e81e7c9a757b061f47ef1e29114ac479aa0953a2687a98", size = 1665071, upload-time = "2026-10-17T00:03:13.284Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f3/ef5b159a0d7161b404f05e7ec21cf7735ee7edc10c000cd2cbbc7bb6b69a/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:ad88921f95b92bc144d9ad059a0e16f8c1ae69f5239f2d3c6536fd2774f73dfe", size = 1473364, upload-time = "2026-10-17T00:03:14.563Z" }, + { url = "https://files.pythonhosted.org/packages/1d/87/6e8ac4a865124a12cc4f915bf1bcfac3bba426cd5f58d3712a873ee61012/ast_serialize-0.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f8b17e2921e56621bcb46792bd3e0b82ca3b85de78d5936e7b8174ba1758b0aa", size = 1500566, upload-time = "2026-10-17T00:03:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1e/29155f1621433008013ad5c940a03d41a676be0680c41527040472d37583/ast_serialize-0.13.1-cp314-cp314t-win32.whl", hash = "sha256:2f9a9ce8b0994f757b941f7f7fe384a6666b4162b941ebc5c248081baea18e47", size = 1128674, upload-time = "2026-10-17T00:03:17.481Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/2506da65143ea138de5822e9173e0b72320a2f70e23bd723f044033175f1/ast_serialize-0.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5d99910e6b9a9b9360bcca2ae99aee36cc912ce058df2a57d1c33099b6cb2593", size = 1163306, upload-time = "2026-10-17T00:03:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8f/c7cb22638899b0cf15908809c270560e46395e65604bc59e4b623ab9f405/ast_serialize-0.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a789cbe14d0a0e9ec9b32a456e5abc81ad8799479c3cfd88a29e3442262aebe6", size = 1133775, upload-time = "2026-10-17T00:03:20.377Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e2/b0c140fe50fa39dd249e382f7733af6f816c90021fe7959a0b026f86237f/ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:23d768369b89d551077dc173da6892fad00443338417523b6732f4e8ba4a7ed5", size = 1236160, upload-time = "2026-10-17T00:03:21.945Z" }, + { url = "https://files.pythonhosted.org/packages/23/c4/a8a2ca21e3943555b48221f8fba546877a7ad39efaf6505506524967d7c4/ast_serialize-0.13.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:68f8829d114dab6c9db4a8bbb8d0e342958faee1b5e15a29022678df639960f8", size = 1222214, upload-time = "2026-10-17T00:03:23.462Z" }, + { url = "https://files.pythonhosted.org/packages/15/19/2f58f2ba64e4799b39de85c140aef09a03aa6ea53be75dd292a9d9b9d8ae/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e385b17d5bde892a6184003a9aff3518387908b73f8f5ea539313c07c8c0ede", size = 1282979, upload-time = "2026-10-17T00:03:24.695Z" }, + { url = "https://files.pythonhosted.org/packages/b1/af/ca6087fe84210c1ccd43e3182917bb70649fa1e69745a90c1d59ba08fea8/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:02686b281ec9b8e9614f6f0db27e12e4cac515f336c5d05b678bc9eb73ff2af5", size = 1288596, upload-time = "2026-10-17T00:03:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/b0/2c/4ef964c763329a0347125e745ec0451ba4fbaceec9efe2ac729b7cd55f17/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8ab2c2585b2e22e48cd4aff2136fce4eab4ced569b85f150ef9a5a5d6f34d49", size = 1545540, upload-time = "2026-10-17T00:03:27.52Z" }, + { url = "https://files.pythonhosted.org/packages/bb/85/aa961dbb75813cfa94de0ce9a16a9510c226b95f604c405421e727f44bb0/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c652d9f5d52d8b81eff3ff2c95c9dd0f281de45f6ad5d3fa95527550c396454a", size = 1309374, upload-time = "2026-10-17T00:03:29.091Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e6/f2b67c327d9f9533e41791804403ccc2dc457821195a2e51f0b587a90b90/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:121e1d08d58b75c15491cc3003609cbad71ac2b0016043c36d30a6b2032b6605", size = 1303466, upload-time = "2026-10-17T00:03:30.451Z" }, + { url = "https://files.pythonhosted.org/packages/73/1d/33f18b81d1bb1f5d682943ea92fda8130a6593627f12f6201668d40824fd/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:30cf89b6a6844542d116c758725d13de5b97b9410a51ee577715f761270d0c1c", size = 1300576, upload-time = "2026-10-17T00:03:32.102Z" }, + { url = "https://files.pythonhosted.org/packages/25/cb/64914144925f1a4abdd9217e717b122255c38fc5ebe4342f92052f3bacbc/ast_serialize-0.13.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:732aaae9e498c973fc63fd7f308009b57c4afa6ff59d0b674d894e57d0bca01a", size = 1355960, upload-time = "2026-10-17T00:03:33.676Z" }, + { url = "https://files.pythonhosted.org/packages/ee/80/bd29379fab2bce19adc0bf9177002b11b23dfe8d8fecf409998b458b4624/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:d9c1a920b2aa0c1e016bed7721689717034ca0dba64ac3a3ff243b003d4e8dd8", size = 1459779, upload-time = "2026-10-17T00:03:35.09Z" }, + { url = "https://files.pythonhosted.org/packages/83/89/012dbe733de05ffdf8e40ca0f9fa5d417cb16ec93a74463c57c001914198/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:847cad4c22953823f5e76d0b126c6f9da670cfa45b1df41b9324e22189d77611", size = 1563399, upload-time = "2026-10-17T00:03:37.285Z" }, + { url = "https://files.pythonhosted.org/packages/69/37/ee4bcb0358bee38744eb0f18560446572d095fe3bf6b63d6f739d08a839c/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:aca07de3ca8e3d9f2b716c3c31f4f384fe4bd2d03e09912417cff7a3e08036f7", size = 1558808, upload-time = "2026-10-17T00:03:38.711Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/ff44c5355251d61ef22934c4430bb31a61de4781d25a992154aac9b8c82a/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:5db12d843d931b9d1f1519ce66b9cbfe72d8563b75c1204d255322254d1964dd", size = 1666324, upload-time = "2026-10-17T00:03:40.376Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bb/6da4bfc8aa3403e6447607131bf3463acaf954d990c6c8b6787c1902ddfe/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:fb8021f4dc3296de18737e3b8b34994411da725374d74a0b9eb32a738f2a23d0", size = 1474089, upload-time = "2026-10-17T00:03:41.715Z" }, + { url = "https://files.pythonhosted.org/packages/21/97/897e33bef2eefd880e6350e375cc66bc5bcd00a139be480fd655f5b3f095/ast_serialize-0.13.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:0d764913374ba21df8cd618045b371ed104ce55c6276de7c59e554a4f5ba26fd", size = 1501542, upload-time = "2026-10-17T00:03:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4c/821211ac83870c27e4412381b7c7838336456b40575bf840e4680b2018d9/ast_serialize-0.13.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:2bad302ae2427454ef9dab71bd5c3ad67188860c39d36daee38f72031edb39c4", size = 1126118, upload-time = "2026-10-17T00:03:44.616Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/2b2d5e6c4fa352874a80618e742ffd32d249dee6c119f4233b51afc6b8f4/ast_serialize-0.13.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:032b09d86c785c3984e975378b7861f2a68ac44e22ba7b07b93a536b2524eeb8", size = 1163766, upload-time = "2026-10-17T00:03:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/d610cfcdabaa44fd62a54ca1fc57a7dba120643798b089a14914973562de/ast_serialize-0.13.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:929eda0c419e622ab49df3a4c45f81c57dcc10874bbad55d0470a4a03324dac3", size = 1134043, upload-time = "2026-10-17T00:03:48.453Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/0e30f9eddf7d44ccf9d25979030754fd0457af61fa4279acb8c293a04f97/ast_serialize-0.13.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bf53a3ba1cbe45399fc1e4ccd684b8f2a11cca450f89706b79122f478534c6f5", size = 897546, upload-time = "2026-10-17T00:03:50.093Z" }, + { url = "https://files.pythonhosted.org/packages/81/92/d705be88ff452b9ba0088b554a0cbd308fd01879da092b33fd2ad7d1aa60/ast_serialize-0.13.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:4bcf1d5f4eefecaa325a34ecf079b2741189e7d0821523f92cb343a389a67489", size = 1242416, upload-time = "2026-10-17T00:03:51.521Z" }, + { url = "https://files.pythonhosted.org/packages/60/c2/98370845a7f35cb8a25782e78284b48e01bf3128339881d48d613f304f8f/ast_serialize-0.13.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1b158f1c9e3a0d386913924c57995d6df78224868a4d0f215bdd41587c97b34a", size = 1229729, upload-time = "2026-10-17T00:03:52.825Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8b/2d7ffed76addd9d3fcf37fb171400942735c8912264c763561a85407f6a8/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77a6201b69ce42e919f2fd9a559138d5410b5d8afff7c3a6b1f500b0481ff7d8", size = 1292255, upload-time = "2026-10-17T00:03:54.148Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2a/bb9e0e719d89cc950fadfcfcc9cb4bc7640419c4228c852f031ab8abf440/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6eb0be64c9b8f1ed26637eef345abe75ea90351c92c85a8aec7838b3af971011", size = 1298501, upload-time = "2026-10-17T00:03:55.559Z" }, + { url = "https://files.pythonhosted.org/packages/7a/32/13ce07dce48226f5bd9cfd8f708ec101a8be05aa430cda4918ce31ce8c82/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f2427bddfca208e84de700044fadf9aef2769a70bae740a1bdabb89ee9e1117", size = 1553758, upload-time = "2026-10-17T00:03:56.952Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fb/9b51d186db0c20ca28cbaaefd50f03b573c89327b9a2627820cbce22196b/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88df960e67184a71874e5b2ee014f87d1254a0dbc1f903c7bd7b1ab7733ecaef", size = 1318258, upload-time = "2026-10-17T00:03:58.418Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fa/da311559dd3dae9af5825cf21660775a929b970f72e3786af3f2cd667944/ast_serialize-0.13.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f99786e0d62082a5b121b3437f3167562133711df22128de1e9886f57f0b226", size = 1313260, upload-time = "2026-10-17T00:04:00.194Z" }, + { url = "https://files.pythonhosted.org/packages/2b/11/97a4b9e5b4776d72f47fcd9a09a497bbf68ec8c1ae074307a2c51e410d85/ast_serialize-0.13.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:a01b515b25a03184eff4acef25b981ae007916379682e908fc8785b342a0bf3e", size = 1309992, upload-time = "2026-10-17T00:04:01.575Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c4/62f85121a80cd451ef04f550cf0f7ea0ad9ff46cc4e9a5b9ae439b6527b1/ast_serialize-0.13.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:29422d7bcd9e7f39411571c36cec45ce8a7a9b3ad4b2e7aa64497c15a9e397f6", size = 1362715, upload-time = "2026-10-17T00:04:02.881Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/c5f4a545f72a7681e3295bab6e3600ee0862ec4391bed961fe9f735b67e0/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ffb0f548a7dc126067fdfb7b2325ab5dfcdeecc29faf5e0f0461c96d6ebfa3d", size = 1469931, upload-time = "2026-10-17T00:04:04.181Z" }, + { url = "https://files.pythonhosted.org/packages/38/a9/65aed4f8fa7a001a7cfdc03e68e844517ab041ba6fb614b7d78a1d6ac056/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2876444a5f87a7c76f371af3c07c2fda563a08bc8af52568df812e8aba4bf568", size = 1570945, upload-time = "2026-10-17T00:04:05.778Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8c/44b2da991cede79bfbf52089f33a5f0168945466b3d9cd38b5dcf08e7130/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:885a62ec6481ce58ab24c6357360d7a82023b62dd7e5e7ceccf59e9662a3272b", size = 1569978, upload-time = "2026-10-17T00:04:07.347Z" }, + { url = "https://files.pythonhosted.org/packages/46/e4/109b2a5beca700237767d5add920c2f8e1ba9bd5799b0faf143d0e9cf4ef/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:2015fd0165e463aac3b1aadc6a1fa1e5820c14ca950821f4fe02f8a3f6a388d6", size = 1676082, upload-time = "2026-10-17T00:04:08.7Z" }, + { url = "https://files.pythonhosted.org/packages/73/e0/7df70f6dbb11b5762da5416318f08316a1c51348e0d69ec79ca5f98f836a/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:eb41e1906502ac80d24ff343f45008552a3f5b5158607959fe6a07dbfc6eb037", size = 1483207, upload-time = "2026-10-17T00:04:10.162Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bc/523aa1c08bd69b5654621beb3dc1a9035715a4a63762fb8d219d6fb3eb81/ast_serialize-0.13.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ca9acc67f796aa2554fa3037dccc022a4b08b38b2ad153edfac9c9bb7cb1f3c6", size = 1512140, upload-time = "2026-10-17T00:04:11.589Z" }, + { url = "https://files.pythonhosted.org/packages/66/83/6607060659998f00afdea0d8375c1e6e2b7d515ab905090d9a5a02f48851/ast_serialize-0.13.1-cp39-abi3-win32.whl", hash = "sha256:4f98da562e5901d69c7dd30885594c5438f859edf354e9ac7517d188fb026c3a", size = 1131889, upload-time = "2026-10-17T00:04:12.953Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6f/bafd6490fe7b355627fabe95f4417e3d02da93102c8552550eb9a0774e1e/ast_serialize-0.13.1-cp39-abi3-win_amd64.whl", hash = "sha256:ddf7cbc9c8abf70102c4a088efe154bb86bfb825b0a10e556d2b15972cf99bd7", size = 1171153, upload-time = "2026-10-17T00:04:14.362Z" }, + { url = "https://files.pythonhosted.org/packages/11/25/4235f4b375a67a37ac595bf478a5407a744eaf7b64113c1c3a5432b8d954/ast_serialize-0.13.1-cp39-abi3-win_arm64.whl", hash = "sha256:48f11d96c9ec71119612f8e79a8c55c12f2b136716d68347433c561d27a25f43", size = 1139035, upload-time = "2026-10-17T00:04:15.767Z" }, +] + +[[package]] +name = "async-timeout" +version = "5.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ae/136395dfbfe00dfc94da3f3e136d0b13f394cba8f4841120e34226265780/async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3", size = 9274, upload-time = "2024-11-06T16:41:39.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/ba/e2081de779ca30d473f21f5b30e0e737c438205440784c7dfc81efc2b029/async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c", size = 6233, upload-time = "2024-11-06T16:41:37.9Z" }, +] + +[[package]] +name = "asyncclick" +version = "8.3.0.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/3c/b266df0e8f3af371692d429eee4901d9392a14c864fa658d1cc7a5145881/asyncclick-8.3.0.3.tar.gz", hash = "sha256:2325fa89c13fab81430d4aca8bac17cb191c1b10a7aa7c13a7eea51cab5812c5", size = 288749, upload-time = "2025-10-03T09:03:23.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/d6/91ba5184580c7d1bb7448749afb0bb515ed5003c81b2cea3eb375362372e/asyncclick-8.3.0.3-py3-none-any.whl", hash = "sha256:a6fca7ed992459d8a807b5409d21aed6245d0342973dc3089a3f60560ea97c95", size = 109718, upload-time = "2025-10-03T09:03:21.854Z" }, +] + +[[package]] +name = "asyncclick" +version = "8.4.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/b2/2c90a32d9b9cf1a4a90ed39cc1177890d146f3f89ae375b061d72110659a/asyncclick-8.4.2.1.tar.gz", hash = "sha256:8f259376ae8d4fcd91961c8ad3e85e9b63c9cf181a122201c7dfed3d7f6024b8", size = 340830, upload-time = "2026-06-30T12:40:33.842Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/44/ee9baab53fe4ff487fa47a1c50b174d3b3fb583cf2e22ff5ee33f61be899/asyncclick-8.4.2.1-py3-none-any.whl", hash = "sha256:dbd533769c4e3ce831c05394027ea1cd606c09688e18abab13dbcfbf7dc5a142", size = 121917, upload-time = "2026-06-30T12:40:32.213Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/4e/59dc964f962f09e3ed472e5d2d3ba670a41a2be25080dc62ab3db507ff5e/asyncpg-0.32.0.tar.gz", hash = "sha256:45e64e56714d888330b884aad1dfb363d0bf43fb343e3d1a8968525f3bade478", size = 1075156, upload-time = "2026-10-06T20:32:40.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/3a/6fa8478896f3f54d1aa7411ae6ba3105c7d3b172ab87d78839bdecc3f2e3/asyncpg-0.32.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd5adfb01cea16908d617af55b00a84c9e581964b77d4301c29fd735bb7850c3", size = 689260, upload-time = "2026-10-06T20:30:25.238Z" }, + { url = "https://files.pythonhosted.org/packages/c3/77/d332193fe023b450b2de89e9c5d35350d95144e3a42ade2ec5131a026359/asyncpg-0.32.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:23638de661ac9a7975278a4fafb1f4c8613e7aae04562675f604dd20ec10e8d8", size = 693995, upload-time = "2026-10-06T20:30:27.111Z" }, + { url = "https://files.pythonhosted.org/packages/31/ee/81338441f0d3749725b0543f199aeab20853fdfaebb749c217d6ed50f236/asyncpg-0.32.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0549af18b697221d1992b7def18aa61652a85ecbe6e19ba2a75277560efe6016", size = 3074342, upload-time = "2026-10-06T20:30:28.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/bd/2460a47ad82956cf6e89e2577711b05b584dc98cc5e379bfc919a25d74fb/asyncpg-0.32.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5faf73279afe1b2137ce503491500b664621762485233ebacb6fb91f7f092baa", size = 3133917, upload-time = "2026-10-06T20:30:30.454Z" }, + { url = "https://files.pythonhosted.org/packages/44/46/7e1e64ba336611e3a0f89c6502578aee34c99c8ee74711b80b0392f9a9a9/asyncpg-0.32.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6e83cdc21ed0a027d3065b19f9fffaf864b91bc007f30bf6e385f2fe84061a79", size = 3007136, upload-time = "2026-10-06T20:30:31.994Z" }, + { url = "https://files.pythonhosted.org/packages/84/97/38c138d7d189eac44f9b1c3e2374a3ce4e42f81e238d99cd1839edf1e8bf/asyncpg-0.32.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:4412cb864442355a6d944adb34c098924d1e14230b6ddbbe9665cffdf2708e8a", size = 3126880, upload-time = "2026-10-06T20:30:33.605Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cf/ee2dfa7b288ef1f5022fb4b2549f10903af78554e2b6ad1fc3e81591647f/asyncpg-0.32.0-cp310-cp310-win32.whl", hash = "sha256:0e25fe441cca81c277554e0f8f7f9c6987d2aaf47cedfc7783d9717ce2853371", size = 542014, upload-time = "2026-10-06T20:30:35.239Z" }, + { url = "https://files.pythonhosted.org/packages/1b/3a/ca9a61df849a7689be13ca3bd956f8671eb895f09a44f5d5b5f9b9c3e201/asyncpg-0.32.0-cp310-cp310-win_amd64.whl", hash = "sha256:0b7706ff96cfe26fc48aa191f72f8076ddc2c52a5bc75fa9d3f34066e734e2d6", size = 607734, upload-time = "2026-10-06T20:30:36.487Z" }, + { url = "https://files.pythonhosted.org/packages/88/a4/281f067513cc765a16ae73e3deffca9f9a959b23d0b1acabeb9ca2d54ddc/asyncpg-0.32.0-cp310-cp310-win_arm64.whl", hash = "sha256:87780aa30b40e2de89717b51cdae4bb80b21b8842c02fb560e1e907e5a856a3d", size = 573816, upload-time = "2026-10-06T20:30:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/a3/27/1a7970f1ece6c205b03c79f45b89420dee9655ffb66bd2c11be8f40c248a/asyncpg-0.32.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5789340b9bcdab94a19eb8ff119322a09991e3626d131b55828535b373e285d4", size = 686071, upload-time = "2026-10-06T20:30:39.115Z" }, + { url = "https://files.pythonhosted.org/packages/2b/47/085934d0290806a92789eee860109c44bea71ff8bc7850a9d3a30da7a819/asyncpg-0.32.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:057ed2455e4e14ad9949f1ac1829112c7d0454c9810b124f36de1486febe6824", size = 692193, upload-time = "2026-10-06T20:30:40.563Z" }, + { url = "https://files.pythonhosted.org/packages/b4/2c/d92524b9e860aecd119c0ebe43f3b9eca26dc2b75c4dfe1be3e999e3f6b1/asyncpg-0.32.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c938c4da9166ac1ef330475e314e2b94c68bde2795be0f4e8a1e00ccd806cadd", size = 3196713, upload-time = "2026-10-06T20:30:42.123Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/3ac7cb86aa287e5bbceaeb783ee6e4f51cd2a001f1747ef4f1236a20bde6/asyncpg-0.32.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:968c570c5913b7ce0995953d7239bd2367142d1af4359f87699f7a6ca75c4382", size = 3260618, upload-time = "2026-10-06T20:30:43.552Z" }, + { url = "https://files.pythonhosted.org/packages/e3/08/618ac36b2970b437d45523f50b5580dba0c34756bbf2153306f82a2697e5/asyncpg-0.32.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96c8226d2026e025852facb5a05035ea5e11b14bebb6b42e4e43948ef8f0d075", size = 3132973, upload-time = "2026-10-06T20:30:45.147Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/54db41b3d5fe26b0401a49327ffce439195c5f6073d8afbbdc9758cb35c3/asyncpg-0.32.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d3f745f4947df9004e2637753ff81d52f305f790f49d67f72e1677db12b07a7b", size = 3251612, upload-time = "2026-10-06T20:30:46.923Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e0/ed1e7536ce949896de29ee955b473659b3daa7887e7081030dba2b15ea5d/asyncpg-0.32.0-cp311-cp311-win32.whl", hash = "sha256:469e6520a839957304582eb8a708d874985914500b64517155f80e6fec00e742", size = 538739, upload-time = "2026-10-06T20:30:48.355Z" }, + { url = "https://files.pythonhosted.org/packages/df/eb/52c4bddad17ff1bee485ae83e08c752a998ef04ac5df76f03fef6430d0ed/asyncpg-0.32.0-cp311-cp311-win_amd64.whl", hash = "sha256:6a1e671e67f4b0bef3c03f37a896d61706f769a83922c119070f1f04e415dc17", size = 610534, upload-time = "2026-10-06T20:30:50.003Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/9af12f2b3300c425a151ef8f85f47c0db76135827c549031858954805ff7/asyncpg-0.32.0-cp311-cp311-win_arm64.whl", hash = "sha256:901bc87b94539f32853bd73a9b02fa78f7feed4cf628824caad3093ec6662f58", size = 574363, upload-time = "2026-10-06T20:30:51.489Z" }, + { url = "https://files.pythonhosted.org/packages/73/06/d5f956db9c936c90cd3289cf948a86c3efc9849e26354356c23da29f6a2d/asyncpg-0.32.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7cb31f7a8472ddc6b6f5c9da1290e901d5c77c8441c7213bd13b13ef6fe6359c", size = 681566, upload-time = "2026-10-06T20:30:52.779Z" }, + { url = "https://files.pythonhosted.org/packages/09/93/ea55f3b26fd40ec90e5b6d6c53b9ff52633cf6b87a468d9c033a727832f4/asyncpg-0.32.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:643d8d6e955a355045dddfe827d74f4f0d1dc4a18e06963a08260af838fbf093", size = 704359, upload-time = "2026-10-06T20:30:54.608Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/a3704e8675d37b168f3584661fc9f64f3021659c9b94e51cf9ab957b2bc5/asyncpg-0.32.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:14ff79ca2574182ce258159c48978a086f9026fc121d935017b5d10c64fa3c72", size = 3707008, upload-time = "2026-10-06T20:30:56.326Z" }, + { url = "https://files.pythonhosted.org/packages/30/30/4fd8d1155b3d7a32a2c241dcb9c5d9e9bd74a59ae71ed25ef8ddb8e038e1/asyncpg-0.32.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:54851411bee2aa51a30d0911524201fbb05f82cc0f7c248b140203db637c723d", size = 3810163, upload-time = "2026-10-06T20:30:58.114Z" }, + { url = "https://files.pythonhosted.org/packages/c1/25/5b0992d45661e1488aba775cf17a2e6c82c7d1d7e10acc71efd394760a00/asyncpg-0.32.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8592f0ed9c315b2117dbdc707cf3292f09a89d5b07661016a84dd881326965cf", size = 3600446, upload-time = "2026-10-06T20:30:59.946Z" }, + { url = "https://files.pythonhosted.org/packages/ea/88/1c82c6feacec813423401b5aef1a43baea951694157f4d405b2d14e80e6d/asyncpg-0.32.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4dbe0982cb3ded878de0867dfaeae3116faf471d484ea28b3e3da942f01fb778", size = 3764563, upload-time = "2026-10-06T20:31:01.462Z" }, + { url = "https://files.pythonhosted.org/packages/84/f5/5a3796088f0c3f7d22aaf7c48536f40b27e44b7c9603d4d7abfeca2ed97e/asyncpg-0.32.0-cp312-cp312-win32.whl", hash = "sha256:fbe1f8c788fb5df18ea8a5432dfa2473fd8f7f088025fb83d089a7c7b37e37b0", size = 551810, upload-time = "2026-10-06T20:31:03.248Z" }, + { url = "https://files.pythonhosted.org/packages/af/42/f4d333a3f67b0e7cf58ea855f9d5d9104ce38c21f2a2f22bf7dce524428c/asyncpg-0.32.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd7157a86817730c3239bc687abf8186a471525d695e225c187b9a523a808a98", size = 626763, upload-time = "2026-10-06T20:31:04.927Z" }, + { url = "https://files.pythonhosted.org/packages/a8/82/9d82e16e1d0b4e2a639a2db649d4b444b8a479cd52553a9c36ba0d6320a8/asyncpg-0.32.0-cp312-cp312-win_arm64.whl", hash = "sha256:9509e21fc526f1fc27cf80ad9f9b8dde3f3e21935d46be66d649635321d3407c", size = 577288, upload-time = "2026-10-06T20:31:06.776Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ee/b6b5870b51e004880d9a216313ea7d4f180961c5869f32e58e8cb9b71e96/asyncpg-0.32.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c032869fd9c3c9fd1a86ad67e53f63906159068087c2674dd1e19be3cffff571", size = 683362, upload-time = "2026-10-06T20:31:08.078Z" }, + { url = "https://files.pythonhosted.org/packages/d8/8b/1f450742bc6eab0c015cae26aef94fac2ff29433e3f18a019126c3912c49/asyncpg-0.32.0-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:0c764dce865b41878396e736d4d2c6c6ce3a8e1b61d1f6bb292e30d265ae7ca6", size = 706652, upload-time = "2026-10-06T20:31:09.524Z" }, + { url = "https://files.pythonhosted.org/packages/05/dc/13f3c0ef7e867bafdccd470e5cfae1f2fd9a7085c771546bd4b94018e043/asyncpg-0.32.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:925ce1cc54419d468bfb77632d91e5e2be5be0fdf9d43680c68fe7cedf87051a", size = 3698244, upload-time = "2026-10-06T20:31:10.894Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/b00ef3fc0d861c28a1937f08d2c7f6e6119c152b414d50fa800c3aee83b5/asyncpg-0.32.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cec40b66a36b14921c155db78631cd96ed00e225fdf38dd5532e9aef350a498", size = 3801314, upload-time = "2026-10-06T20:31:12.964Z" }, + { url = "https://files.pythonhosted.org/packages/de/1b/215067d97a13206ce1565da920ddbefe5a1e5f89903e6de862fdd0a034a1/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1fba43a9a230ce4d2b4593b761b8e03630c613c282b24566e27c7f53695273b1", size = 3598650, upload-time = "2026-10-06T20:31:14.797Z" }, + { url = "https://files.pythonhosted.org/packages/37/45/2bfcb5c9b04df3f17fd367647c9f3ee9fe64ea0612b509a6b1832afcedae/asyncpg-0.32.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a8f7fa8304f757e23cccb8ffef6a6fce0b6320ffc565a884ee3cd0dfad1ac5", size = 3762739, upload-time = "2026-10-06T20:31:17.186Z" }, + { url = "https://files.pythonhosted.org/packages/08/45/e6b37756e6c8979fe070e9821654244f38319493f5b0589e549d9a40c001/asyncpg-0.32.0-cp313-cp313-win32.whl", hash = "sha256:d809399022e244eb86bb532a4ae9a45746e0f6dc5154fd6aa2f6ad63fa3f5373", size = 551065, upload-time = "2026-10-06T20:31:18.812Z" }, + { url = "https://files.pythonhosted.org/packages/ee/46/0a4e92f4310da644b28595b22ef2fff1ffd3dab84953dc8b4c5eef72b764/asyncpg-0.32.0-cp313-cp313-win_amd64.whl", hash = "sha256:38640b106705fef8b0f46cdb5fd9dcf6a638eed5cadb0f441714a21405ca8a0a", size = 625571, upload-time = "2026-10-06T20:31:20.571Z" }, + { url = "https://files.pythonhosted.org/packages/35/f4/48ed4b580b99b1fabc480c707229bb8f1e4ba0f5b24a50822b339efe1e48/asyncpg-0.32.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78145adedfe51dc2fda623e6602cf816dabc2eafcff693bd50484321a1c9034", size = 576342, upload-time = "2026-10-06T20:31:22.29Z" }, + { url = "https://files.pythonhosted.org/packages/25/25/a30ca6417f9142c6a63a7caf5f33717902b2d0ca8a8ff8fc72c6cc2fa77d/asyncpg-0.32.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ac18d9ee7a8ca70aed276f79b249d9f37e4d55e3525db1002b5f0b62ddec4f5", size = 691699, upload-time = "2026-10-06T20:31:24.168Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b5/59f10f2381a073c199cd868fce0d8f7aa448b08412de4dc4dbe4118bcee9/asyncpg-0.32.0-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:e1120ef2ae3a5e514c9ea9fce83519ba692710ea5f38434eadbbf12789073dfe", size = 715194, upload-time = "2026-10-06T20:31:25.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/59/79a5aebd58250bedefa6dcd43b22b037d9cf0054ceb4c718c53ebf04e63f/asyncpg-0.32.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fa68acb42f22436597016e5d7feef7b0b5c49b4c56aece3fdb3ba0da2326cb2", size = 3729978, upload-time = "2026-10-06T20:31:27.541Z" }, + { url = "https://files.pythonhosted.org/packages/68/db/fc91b503b3ec66cf242d83c799388285ea5f0ee238435d53dd9c1a8648a9/asyncpg-0.32.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63417b8f7369c54f6754c1fbd5a2968fbe632ff55bfbedd56a0177b6a96bd251", size = 3794539, upload-time = "2026-10-06T20:31:29.617Z" }, + { url = "https://files.pythonhosted.org/packages/40/bd/7359320499fdb2733206191b8fd15b7ec602656cbc1444bff7a8c66a365c/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c6366841a792d0a4d16991de240a8053b7c4772a18a5f27fa6fad09c0e359fb", size = 3632884, upload-time = "2026-10-06T20:31:31.298Z" }, + { url = "https://files.pythonhosted.org/packages/18/75/dd3c3dd99f1db55b9736d23a44da29501f07f852bf4df91507f37b156fb1/asyncpg-0.32.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3ef1dfd11919280e011ffd1c873323c5088a94fd2c3f77946a5250cf306e2eb", size = 3764931, upload-time = "2026-10-06T20:31:32.916Z" }, + { url = "https://files.pythonhosted.org/packages/38/4f/161b275759725a774d170a383c1208996865ebad50d6891e60d35461a3e6/asyncpg-0.32.0-cp314-cp314-win32.whl", hash = "sha256:77cf9d7023f063ae6f9e443077b55af0dc1807dd9afff1ae656b93ee0cddedc9", size = 557690, upload-time = "2026-10-06T20:31:34.856Z" }, + { url = "https://files.pythonhosted.org/packages/b5/03/880d0db1faedf8b740a57a7ba50e115651a0f05c5905140195813879b086/asyncpg-0.32.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f87452025b47ce80dcc3a0be2b5d1f8aab5deec2516d266f1643d4e53cc40d5", size = 634859, upload-time = "2026-10-06T20:31:36.512Z" }, + { url = "https://files.pythonhosted.org/packages/79/bb/2e86b462a2a2a795eaa7838266db019876b8e7a12c465b903517a4e87fd0/asyncpg-0.32.0-cp314-cp314-win_arm64.whl", hash = "sha256:d0e4508a3d62b0f42d7a99c030c364050b11e75f61c9dd4861e5fdda7cb60636", size = 594013, upload-time = "2026-10-06T20:31:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/20/1d/5369c4438496e654121cbda75be2e8043d1fcae3552b856d44011a19b723/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:afec11e0b9c001e69966becacd2f948cc8949b4916ec4c0f4dc9b52e47de4528", size = 743832, upload-time = "2026-10-06T20:31:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/60/b0/4b92582c2339a164275a6418ccaeeb0453b72f2e0d7003702379cb50e852/asyncpg-0.32.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:418d266a553e932bf961bb43bfd610ee6c5425fb1b9a599a5828fd12bae8f5c4", size = 769568, upload-time = "2026-10-06T20:31:40.691Z" }, + { url = "https://files.pythonhosted.org/packages/3d/88/919d9ff7ca3c3b96aa404b88b6a53e142b4422623c5ee5a69c4b733240ce/asyncpg-0.32.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1666e1b747ebbc75c87cb31972704ae8a3ca15b950f94456e97d26781c67d10", size = 3948962, upload-time = "2026-10-06T20:31:42.456Z" }, + { url = "https://files.pythonhosted.org/packages/27/8b/e9f412ae9a3e3f0eb23415249e8d5933e7aeb01068b4083fc86714043d1f/asyncpg-0.32.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83510bb25d38f0415e155aa3a7af78621369891f5ecd8730d012d9cb26143ffc", size = 3874815, upload-time = "2026-10-06T20:31:44.094Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/24364e9ff7bb9860548452513f295306b12f5b24e8fb0b78f1605c443946/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:87957755d11639cf248c6aaa094eee9d150f07065866d1710c9427e02dfc0790", size = 3762465, upload-time = "2026-10-06T20:31:45.908Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/33cb7e805ec6806b196473e2c7a2ba9d5af3ad2928930aa06359c8eeef87/asyncpg-0.32.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:764227423bf30a3001d3da6df90e82d30a2a097d762e4ee5fa074236eda262f4", size = 3797285, upload-time = "2026-10-06T20:31:47.53Z" }, + { url = "https://files.pythonhosted.org/packages/be/e7/85eb86d6040725f5c191fd6af9f10769c60ed971634b47f4b4bcab293d44/asyncpg-0.32.0-cp314-cp314t-win32.whl", hash = "sha256:f2342b1f3e87b2096320a77edcbb830fbd23b1d4d4842c57567764430b95e4fc", size = 594006, upload-time = "2026-10-06T20:31:49.197Z" }, + { url = "https://files.pythonhosted.org/packages/f9/aa/ea75defe55718457bcf41cde42248db5bbee65fce8c6f0a0e43d9eca1723/asyncpg-0.32.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5c3a48908cb0a02393e5bdab7fa92aefd700f2a93212bf91f04aa9657b4f554d", size = 674647, upload-time = "2026-10-06T20:31:50.547Z" }, + { url = "https://files.pythonhosted.org/packages/0d/0b/078d362872c6c72dd5d11c214dde8dac65b1c87ece96fd2fc2f786a8f66c/asyncpg-0.32.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f8eadd207c26850a2e15f3c2a1096b5d051ea6758a26f2f3e65ce16f84297ed8", size = 624589, upload-time = "2026-10-06T20:31:52.291Z" }, + { url = "https://files.pythonhosted.org/packages/5c/83/e0145d19197b965438693179c88dd99cfc69bc1bf954815f44762ab88843/asyncpg-0.32.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:58975b1a51a100c4716ebf22f84c249d27140f7b9385b64ad9b676836f1db9ab", size = 689708, upload-time = "2026-10-06T20:31:55.809Z" }, + { url = "https://files.pythonhosted.org/packages/2f/13/f394919a59f104288b1b17fb6c7a3ac4738b8c555690a63caf603f91ca83/asyncpg-0.32.0-cp315-cp315-macosx_11_0_x86_64.whl", hash = "sha256:6b95fc2ebdb4af072bfa8b64c6d0397b49242d17bef1c0337857904f9267dab2", size = 714408, upload-time = "2026-10-06T20:31:57.504Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3d/1123cf41bff78fdfd80e6fd143cc86bf1ef2875af8f5d8742c03f471e913/asyncpg-0.32.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a759f98c5652443db501b20041aeee548e9a04fe7ae939067321acd207218447", size = 3733440, upload-time = "2026-10-06T20:31:59.308Z" }, + { url = "https://files.pythonhosted.org/packages/de/24/ff4b045e85d7bdf6f61f67c285800abd6e82f26319671d7f0dfadadc1aa0/asyncpg-0.32.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ceea1064500d0d7a46c092cdbe9752064c23b720ab0e0bff83d1030fffe7a50a", size = 3824312, upload-time = "2026-10-06T20:32:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/12/63/1ec7eb6e20f7e8ae120a41aad9669044cce964f39773baf644897a046aee/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:543f02790d086244c7cdc849e4b671b6c2048be0242b78d943494da6e80c0001", size = 3637212, upload-time = "2026-10-06T20:32:02.699Z" }, + { url = "https://files.pythonhosted.org/packages/79/68/528e362eb5adbc1a7defe4c5f157756a031346d3efa9920467b245e4ce41/asyncpg-0.32.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f24d20a68f0e37ca6fc490388e7eeb48abab3da0dbf06248135ed6179f5f521d", size = 3791355, upload-time = "2026-10-06T20:32:04.415Z" }, + { url = "https://files.pythonhosted.org/packages/38/e3/22f443f456bf93d1806f43a820da8ee463dfe9b93a9d77a3f00fedcdaad6/asyncpg-0.32.0-cp315-cp315-win32.whl", hash = "sha256:110f72d33c8b944ab421ca383db0b8849cfeb861547fee6cbb61f65a6bcd0985", size = 557457, upload-time = "2026-10-06T20:32:06.52Z" }, + { url = "https://files.pythonhosted.org/packages/54/d5/ccb76555a333f543c4d6ad6422b616efc0811dbbde5054fda071e249c7bf/asyncpg-0.32.0-cp315-cp315-win_amd64.whl", hash = "sha256:6d1d1cd1348ebb9b204b5f56f977c5d4380674c25cc094064bf32bd9c3b7273d", size = 635573, upload-time = "2026-10-06T20:32:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/38/70/dff17e837ba0eb4347bb33da33f54df87230d3d176793d4bb2ad7786b1b8/asyncpg-0.32.0-cp315-cp315-win_arm64.whl", hash = "sha256:cd5d16b3a5db37c1e6e445e362952b4af569f85f94e162f947bfa8ea25a45fa5", size = 594218, upload-time = "2026-10-06T20:32:09.717Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b8/c5506dbde0cfb213963210fd0c80e60036ddaaa883ac0d3c55d05a10ebe8/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4ea1a72a00fe705b68a9727c3d538c4c56690af9bb1cbbf3c089f5d3ddcccea0", size = 741693, upload-time = "2026-10-06T20:32:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/23/98/9f998c651aa5d66b59ab6c13da71a15d74ccb1ddc4d65290ea5e2e5aedc1/asyncpg-0.32.0-cp315-cp315t-macosx_11_0_x86_64.whl", hash = "sha256:ed3ae4c3659aea1fb0e3a6c1061fc4c64d9b7a2a8f4a27443dc43d74fa84cf03", size = 768101, upload-time = "2026-10-06T20:32:12.948Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ce/d8c63a71e908f5d80de1a3a057c8407aaea07cf19980d4b24ab624943c99/asyncpg-0.32.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db69b9cf879bddeea41210c80b8c8877bfe2709e2bee9d18d5a5c00e7eb75972", size = 3940715, upload-time = "2026-10-06T20:32:14.544Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a5/5d2b17682e297e39206eda1dfe0120fc239e84d3440b39ff7c9cc7ec83db/asyncpg-0.32.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bee7bb5394bf55fc3bf4144625c33f298949961acdb1e0d67e60f958ac9a2e6", size = 3907504, upload-time = "2026-10-06T20:32:16.212Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/38ec7277f31f26267a0a0547d0997d936850d05007d1e0e1041bf8070e1d/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d74eabd68e68861333e3fcb92b520a2a851f6485abf4b723887590399d4980c1", size = 3750324, upload-time = "2026-10-06T20:32:18.061Z" }, + { url = "https://files.pythonhosted.org/packages/dc/74/089e80eda7d543a49875687a84121e2ad61a7c69698963623ee77372c4e9/asyncpg-0.32.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6af2af292a93d5ef800007c8f8f66b85af2a49b49e4b56a10685a0dc24a6af83", size = 3826457, upload-time = "2026-10-06T20:32:19.757Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/38104e60cda6131977f95b634d45536ddc1cde53ef8bc765f9056e3e17ee/asyncpg-0.32.0-cp315-cp315t-win32.whl", hash = "sha256:d148cb6a9081ed999ca3cd0d95fb9eaf79bf17d885bba93c83de52273d2fe0af", size = 592437, upload-time = "2026-10-06T20:32:21.668Z" }, + { url = "https://files.pythonhosted.org/packages/95/09/85cba249db0910708826ea428b32a4a05630df993621c369bdb8d42c73c5/asyncpg-0.32.0-cp315-cp315t-win_amd64.whl", hash = "sha256:e101801b4124e905da0732cf2b0d838f682a9ea5273d7cced3d54bdbe744e6f7", size = 672417, upload-time = "2026-10-06T20:32:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/38/11/ec5f7f306dd361aa9558f002cbb6acfa1e9ba32fa59b8f53135fbdfa14f1/asyncpg-0.32.0-cp315-cp315t-win_arm64.whl", hash = "sha256:3bbf08c08e31f43be858255614518e78cdfb343571e557e818e9fe736334f4c8", size = 622767, upload-time = "2026-10-06T20:32:24.64Z" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" }, + { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" }, + { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" }, + { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" }, + { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" }, + { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" }, + { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" }, + { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" }, + { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" }, + { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" }, + { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" }, + { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" }, + { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" }, + { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" }, + { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" }, + { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" }, + { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" }, + { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" }, + { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" }, + { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" }, + { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" }, + { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" }, + { url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" }, + { url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" }, +] + +[[package]] +name = "certifi" +version = "2026.10.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", version = "3.11", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and implementation_name != 'PyPy'" }, + { name = "pycparser", version = "3.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/d2/2cde336b375f55c76ca670f0be3978cc048e31e24f3b4d7ce8473150a388/cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be", size = 183779, upload-time = "2026-08-03T21:19:15.602Z" }, + { url = "https://files.pythonhosted.org/packages/94/1a/4b2f7c92293ba05cbd4a9a1b28faaf0326272d9488e6354657571c48a7aa/cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b", size = 184178, upload-time = "2026-08-03T21:19:16.67Z" }, + { url = "https://files.pythonhosted.org/packages/17/0b/ba385d8ccedf926c3cd06e8e2f327027da5afe5f0eb30f1f7bc43ac55125/cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004", size = 211037, upload-time = "2026-08-03T21:19:17.705Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b9/0f2e58b2cefa33255bff36935d42b13180fe559bba82596540eb404bde7d/cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9", size = 218652, upload-time = "2026-08-03T21:19:18.735Z" }, + { url = "https://files.pythonhosted.org/packages/37/15/180e0dab27b9312c7479003d14c9e547634b7dcb934e2cc4650e1b131a7a/cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98", size = 205422, upload-time = "2026-08-03T21:19:19.96Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/03026f0c850cbbaa9030750490225b4a7f4d524ea4df72c3cc740a90f4ef/cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9", size = 205444, upload-time = "2026-08-03T21:19:21.246Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/60bebf6f818bec84210ac5b6979ce4eeadce6fbbaabc9c7ab23e506d1ce5/cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6", size = 218742, upload-time = "2026-08-03T21:19:22.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/679bf47e73fd77b352171727f07de559a003f14de5d02b904a6ec1fa73ca/cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf", size = 221054, upload-time = "2026-08-03T21:19:23.694Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/eefc0e06913b70aa153bf74c946094a18f58fd4aff11b7f372bfdfdca050/cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659", size = 213489, upload-time = "2026-08-03T21:19:24.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/13/4e56852824a03cdf68523a35686f1c28eacd4bd30a7b0a78e682e6e6e1d3/cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9", size = 220241, upload-time = "2026-08-03T21:19:26.214Z" }, + { url = "https://files.pythonhosted.org/packages/99/7f/040f9e163e4acac3ee3d85b02d00b2576e7ca980d8785f0a3a5f1a9bf7f5/cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41", size = 174578, upload-time = "2026-08-03T21:19:27.338Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0b/644a2ec1a4eaba49c2939410bb1eb1d25b09d6d0582f5d2f95c537043725/cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1", size = 185082, upload-time = "2026-08-03T21:19:28.409Z" }, + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/77/46e87bcfc45d25ab4db7cfc9bb544bfa3ffd302289ed31ae93f5433eb899/charset_normalizer-3.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:195c26fb65950f8fce54e26349852b7bdd7c5f120aeefbcc440b8a20faaed4a3", size = 373348, upload-time = "2026-09-30T04:34:42.843Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/e518013affcc43c9f919c3ba41bffe9b4ee4aceb0a6462a6243efcca5f2d/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9373ad13ef0d2c0fb761e04e55bfdee5a08b52cef2c882c8fbe9935b1517152e", size = 260860, upload-time = "2026-09-30T04:34:44.817Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ad/2a895c945ee61988dfd9ccee64f0b78dc09f29c9b34d1dd545246d78e0ad/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ddf19c062bea7a0cc80f519243d2c01dd091be0cf952a0750d4ad576709559f5", size = 246491, upload-time = "2026-09-30T04:34:46.227Z" }, + { url = "https://files.pythonhosted.org/packages/c9/05/5d958bc8ea503e26be25ada5430fd409cfb45dc22ff33f9f96e649613c99/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d14b50de6bf4d0edf857a9386836846f982b8f524e188e2e68b96d702bcf4aa", size = 290875, upload-time = "2026-09-30T04:34:47.691Z" }, + { url = "https://files.pythonhosted.org/packages/55/e2/06bad57dfdb49cad92c0ba85b6b4fa5827a67df37897287cfef0553843c2/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:28a15fdad492a99b6eccfaaed66ef3f74050680545ea61ec8b2f4c538f1f1320", size = 286987, upload-time = "2026-09-30T04:34:49.356Z" }, + { url = "https://files.pythonhosted.org/packages/c4/9f/9f52d2886d52645987d603425482c63c5045a3005db1354a7097e5ed1ae9/charset_normalizer-3.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a893cc101149f80a653f82062ebc95b34525a2614382e1da5458fe7c6997249", size = 271105, upload-time = "2026-09-30T04:34:50.957Z" }, + { url = "https://files.pythonhosted.org/packages/4a/41/e05e19578b7b87e7db2c3ecb884bd09d065274ab41e1f535542e8bae9b06/charset_normalizer-3.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:619799369eeef6366ed3e8755a5670f4f2f0fb6b30a0fd7264dc0fdc2357058e", size = 269833, upload-time = "2026-09-30T04:34:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cd/fc7152414561ff78f9c61a4a627025f65fd244abaefc99ac4e75c6169b33/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:447441e76ec720b15e64418d32e092297340387053047c7c694f579efb0ee1d9", size = 263048, upload-time = "2026-09-30T04:34:54.013Z" }, + { url = "https://files.pythonhosted.org/packages/71/71/fb379e399b1013962716a059d551e03bd80b3e02f05d81246886af6c0958/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:62588a277bfb59def052abd940703fa35107152bf479781a878617d60faf8fb5", size = 249237, upload-time = "2026-09-30T04:34:55.373Z" }, + { url = "https://files.pythonhosted.org/packages/33/4f/aeadccd6d20882909eb2597ec40eccc05d505e2262f14dc76d7620700657/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:44bd4fbb29dfbeba60e7d2bd000c59e4b21ddb3cc53912b14048d37092706d7c", size = 290756, upload-time = "2026-09-30T04:34:56.867Z" }, + { url = "https://files.pythonhosted.org/packages/b9/7b/e8a92613236b257d3b75d532496eb21f68ad8231d7d56df94497e460112e/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:30fcd120b732aa79317f08dee04d7de0847822e4cf7ee0e9f445bb958832252c", size = 269026, upload-time = "2026-09-30T04:34:58.278Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ac/21d5c6b972285c5f095ff78afdc99f3539e3985dfcf7ff1cfbe9e772f529/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:50e3adfb96fc189eb27b1cf62d3b598b89b4bb0420d93a3d3e42e137409011be", size = 287601, upload-time = "2026-09-30T04:34:59.703Z" }, + { url = "https://files.pythonhosted.org/packages/59/72/263491ec1494a194b16fcbff88a0220f2af633738c79e92b4d7189226322/charset_normalizer-3.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b736353c0a625bbd5fcec108576e2385db3496f4f771f785ff32e108d3c3bc45", size = 272660, upload-time = "2026-09-30T04:35:01.26Z" }, + { url = "https://files.pythonhosted.org/packages/38/7a/81f1186c57e406233969ae96b5fd0c907254ab8b6e47da8ae24cd20fb9d7/charset_normalizer-3.5.2-cp310-cp310-win32.whl", hash = "sha256:f5833ad231be5eb6553de524a70f48d71b2c8563101750531e0b80184e175cd4", size = 187301, upload-time = "2026-09-30T04:35:02.659Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ab/fb8b5178fdf7bf68efa61cbe892286bc9f73846f26147d4725fb89388475/charset_normalizer-3.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:1461ac396c4fdb983a675f20aa555624f0ee18ac83d832b9244ffff3d8055275", size = 215446, upload-time = "2026-09-30T04:35:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/36/40/e270b74e0d3f583d78d902960845a149e80eb7c737ee5613ccc6efc7264b/charset_normalizer-3.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:c6708715abcf3c73b99508253e961a9967f02fe536532834149574eda6de0d1c", size = 203389, upload-time = "2026-09-30T04:35:05.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/67/6a0b94a7960d5e1b5eacd2fb529f3fccc47db4644f7f0a7cfdcfc3be578a/charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6", size = 370642, upload-time = "2026-09-30T04:35:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/94/01009e13b94041599004edf32e56e382c24e570f60f79bab8efe45cfe1eb/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5", size = 259280, upload-time = "2026-09-30T04:35:08.448Z" }, + { url = "https://files.pythonhosted.org/packages/66/85/3b5358f60a13210f0b67d3755c168ef758701b021e655d88d4da28554467/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74", size = 245335, upload-time = "2026-09-30T04:35:10.104Z" }, + { url = "https://files.pythonhosted.org/packages/74/75/77c1c479b09ecd751d1e767b251ea5c14d4d50ff757bf404afab2692f600/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab", size = 291707, upload-time = "2026-09-30T04:35:11.575Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0d/363f78cacb70f58f15f4b083961bbd9d292f335d3f5c66fc4f1cfe69cb90/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c", size = 286747, upload-time = "2026-09-30T04:35:13.022Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ed/cf505d3011ffceb12c2067a7a5d3cfe92b875d4d44bb0ff0d69375e2c184/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f", size = 269972, upload-time = "2026-09-30T04:35:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/15/d8/f0a93a431d170e7ca681d4f6650fee3de934d18560e474e7267eb4b0f987/charset_normalizer-3.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288", size = 269339, upload-time = "2026-09-30T04:35:16.087Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/9b2bd1c5b7af02462c9752d33994834ff972a96b4c483eefde9e594488e2/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400", size = 261683, upload-time = "2026-09-30T04:35:17.488Z" }, + { url = "https://files.pythonhosted.org/packages/76/a5/cac540ab0fd61f3fec88ad3dbb64509e71424593d73cfdfff5ab3e4db279/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd", size = 248143, upload-time = "2026-09-30T04:35:18.849Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/ff467301deef2089fad87f72df9e000a26a78fec7acbb18e1999371b8369/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37", size = 292358, upload-time = "2026-09-30T04:35:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/ad/77/22d7e785d1e210afc2e2f58600dd1799d17a35665faf84383f002826c5f8/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac", size = 268766, upload-time = "2026-09-30T04:35:21.72Z" }, + { url = "https://files.pythonhosted.org/packages/ae/91/e8e946267f1c2d9e2bd651726e2fbd2addf02c4d36cea5069e32ca9d7bb5/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a", size = 287432, upload-time = "2026-09-30T04:35:23.273Z" }, + { url = "https://files.pythonhosted.org/packages/4e/88/7561d8a88d555e7df6623abe7c0070b4baf47549b9408783a2ae0a1a6cf7/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640", size = 272470, upload-time = "2026-09-30T04:35:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/35/7e/578c702301ec036f01455f30744a08d2b42f6ab35b9b2d4bf8cae0ef2a80/charset_normalizer-3.5.2-cp311-cp311-win32.whl", hash = "sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d", size = 186688, upload-time = "2026-09-30T04:35:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/e8/fc/fdf8cf52ff21cd5bf158f20978991cf985325842f74283eb6df26c8a39d8/charset_normalizer-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96", size = 214932, upload-time = "2026-09-30T04:35:27.796Z" }, + { url = "https://files.pythonhosted.org/packages/97/66/3e45a506d8110b632541faf9a9470185aa9878f1ed44020f31346c1c5e5b/charset_normalizer-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1", size = 202828, upload-time = "2026-09-30T04:35:29.259Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7a/f08d34ce09d60f89ebd391e2ebc6ba2b995e6dd7552f41820f8085f94e53/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67", size = 4716035, upload-time = "2026-09-30T15:29:48.681Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/e18fb65592451a2acb76e9f2fbe14e0f47a8318b4c5430f1633851d03daa/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a", size = 4726917, upload-time = "2026-09-30T15:29:50.608Z" }, + { url = "https://files.pythonhosted.org/packages/83/28/38fdce17e60f6b825e69fc3b7f75e70a6612759980704697e1de4cbfaf6e/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48", size = 4715341, upload-time = "2026-09-30T15:29:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/d9121a717e0f893c64bd6ca7702614778d7df2a5c309128a002421788516/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42", size = 4726322, upload-time = "2026-09-30T15:29:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/e6d153808bf353e152abd2fd4d8f09670d956ac78379ac46e60d7efbf04c/cryptography-50.0.2-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81", size = 3873097, upload-time = "2026-09-30T15:29:56.097Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1d/1271f287ff7170ddafc2aad36260c4eec20ccd2fea70f38455e9d56d427b/cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452", size = 3805376, upload-time = "2026-09-30T15:29:58.729Z" }, +] + +[[package]] +name = "dictdiffer" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/31/84b2b2113c2c972431582bffd74f0d04b5efd9126538127b766275580950/dictdiffer-0.10.0.tar.gz", hash = "sha256:0b912acf663949d73b820d3ed59362140a188ab742c94efe13c762dbd24cbbd3", size = 12451, upload-time = "2026-07-16T12:39:07.886Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/2f/7ff60ad2cafd7c970bea75da5f4a9b84b784f7340abcb5fc634d4cf5cd37/dictdiffer-0.10.0-py3-none-any.whl", hash = "sha256:6ca50f38f7c5ee27d52789eee095ae473d9e02e1aa7612cb3aaf25fcf081dbf6", size = 16060, upload-time = "2026-07-16T12:39:06.842Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.75.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/2b/6ce81972d5c8cab9705fddce3153be63222d9e12fd96f8baba5038a744dd/googleapis_common_protos-1.75.5.tar.gz", hash = "sha256:c7a866fc34ed29a3b10af627a4b9b1dc2433313ca6e959f0ae4feb132047ed72", size = 156513, upload-time = "2026-09-29T19:26:14.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/b9/6b29500a1c581ff4d77fd83c6568d068bee06f1b139fb6eb0a4f2d4bce8a/googleapis_common_protos-1.75.5-py3-none-any.whl", hash = "sha256:d7285525c23039db98f2463e6d5a4f9b958b94d497f03a844ece3259c4e72d5d", size = 307737, upload-time = "2026-09-29T19:25:48.735Z" }, +] + +[[package]] +name = "granian" +version = "2.8.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/63/c2548102956296ee0f8671291dd5a6f3f3dc2fc7c4e62e0b86e3be6de571/granian-2.8.5.tar.gz", hash = "sha256:032588689e2d524d633fcb088ee54fad4c5655d0af1a75058d5b4240be4f6864", size = 130933, upload-time = "2026-10-20T11:43:38.079Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/82/71f4ecf6fbe2eb7f9f32c2b73527c52ad1213d8d765876cf4a2175caa2eb/granian-2.8.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:98f09b0539e9dcce6ee534232b2d933ba97f27173675ef5a177d01380aa76371", size = 4592856, upload-time = "2026-10-20T11:41:32.556Z" }, + { url = "https://files.pythonhosted.org/packages/45/27/56b851450114a64e492f2a5ee3cbfcabbaaace58ccba00ab0e7dc101c4ff/granian-2.8.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cf900618c67163d7c881ed2cbc995fabd315a72618fd334d8101276fc50a5f68", size = 4311235, upload-time = "2026-10-20T11:41:34.606Z" }, + { url = "https://files.pythonhosted.org/packages/66/f1/64c4cb8de1aec462d99e9bb0f87709eee3a5c18951845d2a0d9d96f25d03/granian-2.8.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:88245684c009a66ab23c73ae1308763fc5e21a703cab75d4f9ee05ce1938836e", size = 5078378, upload-time = "2026-10-20T11:41:35.881Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d9/8445f7a40c6a245fe03654f9f498ca63a987352517fa96e854684a88e890/granian-2.8.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d5c84021e3f548bae8b3efdc27935a374f5348fffb1f0b478ae62a9b533d2aed", size = 4592000, upload-time = "2026-10-20T11:41:37.161Z" }, + { url = "https://files.pythonhosted.org/packages/46/bf/7e6a90f84363700f18e347be11edf642bc2ac8c839bc965b683a62abffb8/granian-2.8.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46c54bc18a182cd57319076ef96d89c3be13605aaeafda1ecad999bd0449d201", size = 5047109, upload-time = "2026-10-20T11:41:38.627Z" }, + { url = "https://files.pythonhosted.org/packages/29/e1/656503580d5ca6f12872cd53ee452f9f222fb7a65468aaa39d7e3bad1602/granian-2.8.5-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:62f0d98695efa53b0f90ae211eff7c485009777378ad33af1ba46421173c239a", size = 5093769, upload-time = "2026-10-20T11:41:39.937Z" }, + { url = "https://files.pythonhosted.org/packages/99/92/40cf612d8ca16434ba3370a9a093d5a4d5040b52c0232ca960b0a719899b/granian-2.8.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:55b19896b5ddfff20a48bc07314d3f1136a04401e9e1b2a13dc5e35797f77f1a", size = 5125540, upload-time = "2026-10-20T11:41:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/bd/16/50ecc5a575c38fb2d0bc2ffd2c35cd90dced4f0595b466782147788e80ea/granian-2.8.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:6baaace6a81a2068872bb60e550b9d9119c1143db7534d9cad8aa3e64d9c3059", size = 5136772, upload-time = "2026-10-20T11:41:42.992Z" }, + { url = "https://files.pythonhosted.org/packages/fc/08/6241869f4dcf7fed403331803fe0851b73b04da17f00bb18f461d4b39193/granian-2.8.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e531e9c22e527c9c8cfbf188fc446269011bb15370ec033b4f3b6decd52e215f", size = 5139950, upload-time = "2026-10-20T11:41:44.467Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f2/8d9241cf97c6487330dc313dd60016ea199ca4056ce79cb8064ae050eae2/granian-2.8.5-cp310-cp310-win_amd64.whl", hash = "sha256:b54346f0e8a83b6d6b2ff23160fcbac17329540c45c3ebd1f968966d70ff3cd8", size = 3243248, upload-time = "2026-10-20T11:41:46.15Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1d/c5eb0aa2ffcf6d18c34e2a0f77bc731b1f5c1159357315be036c5ac1eb30/granian-2.8.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3203ffa641bd6f16b2951afeec626178880b702d04e803a16dbbc38c1b25f859", size = 4602986, upload-time = "2026-10-20T11:41:47.423Z" }, + { url = "https://files.pythonhosted.org/packages/76/0d/731274bc814123f0ad1fac977202b65d2a703819a0b37c54315a4b7b78cd/granian-2.8.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dac7fe7e8263000f74387e07e3a178279d971039bd2b6d029129798ef9e2f59", size = 4311541, upload-time = "2026-10-20T11:41:48.658Z" }, + { url = "https://files.pythonhosted.org/packages/27/4f/d66f6701a770dedffe845581b9f03ff7ad7713a7ba5c38ad527fbdafbba4/granian-2.8.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:23d77fd0fe124fce9829ddbaa5b73c357580634331ca294f57bf37e9b8cfe834", size = 5151321, upload-time = "2026-10-20T11:41:49.913Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/67d0578d42bf97ecba89510a384da94dbdc147550d9f06e814ad14b08593/granian-2.8.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a8435d25cf9427e954d6133dab0a07ff5fecab38e243ba28172ffcda3e7c2664", size = 4661179, upload-time = "2026-10-20T11:41:51.147Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/0a59603a3733ab46bef1df03538813de11a510bfd97a324d5072c8370563/granian-2.8.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85fcdbdf012cdea0eb628b2858dac2e9137272c72d16b52d25b3cf014450f8c8", size = 5032423, upload-time = "2026-10-20T11:41:52.414Z" }, + { url = "https://files.pythonhosted.org/packages/50/89/a5883d04b5dad8f01fcb66803cadcf6fe900d1150cd715f5b08742e2e952/granian-2.8.5-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:d733a133b381abfd4ab5035346071c81480e9ff2abedcb4375f10ce1c7c68794", size = 5052813, upload-time = "2026-10-20T11:41:53.829Z" }, + { url = "https://files.pythonhosted.org/packages/57/67/5151a391bc39fb2628e995332480e7efa5d83db69b499cb0aaac6832cf55/granian-2.8.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d9438061a319f5f621ba9c963baa760f62f907d95c21cdf10cda6434e75879f5", size = 5084084, upload-time = "2026-10-20T11:41:55.228Z" }, + { url = "https://files.pythonhosted.org/packages/20/ee/2de690e2dc0c6978f6d392f04c3c4d74c04fa7e780c669bce2332b868a4a/granian-2.8.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:ce17c1f040b8595ca65d6638a920590c8511e5c27365c7222653d0154480636e", size = 5204812, upload-time = "2026-10-20T11:41:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/11/0c/009b9fc714dfb4d0402b8ed36bad04a2567d4b77cbba56712549550a335d/granian-2.8.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:06fa5fbbe2b1dcc66a3384bbe1d156730ff0d6128173f9fa35b999d9b6eca500", size = 5123155, upload-time = "2026-10-20T11:41:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/dc/34/65d14a23c9f3d84228862451d3d84aaf5c06289fa5628b18dfd42616bbce/granian-2.8.5-cp311-cp311-win_amd64.whl", hash = "sha256:84db0de5ffd800bf912405daa08092ec19bab5aa05e1cc65236fc721c0aa0fb3", size = 3231999, upload-time = "2026-10-20T11:41:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/27/91/103c04ea8ef7d9d40cdecb96a07cae09bfe9e3857d39b8174fc5c1a771fd/granian-2.8.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3404a620364f3816f126863910e6dc86da833298608896e556c01c9bf52a63c2", size = 4600588, upload-time = "2026-10-20T11:42:01.027Z" }, + { url = "https://files.pythonhosted.org/packages/df/f2/52735de5caa6db560bfe91b60c97f5a1bf57a035ccd08004f21f18762830/granian-2.8.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dcb0ecee7650794738fa347ce1aac5a7f790bf36dc0dace4194578335ad3fa60", size = 4310695, upload-time = "2026-10-20T11:42:02.305Z" }, + { url = "https://files.pythonhosted.org/packages/60/e3/274741428e960bf59b12c51cdca2340698e708f3bab4ec6fa16b0765d12a/granian-2.8.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a5e1459736acd617631524db3c430df7b7fe2fd55b6f21536eb9d30a603d41", size = 5156401, upload-time = "2026-10-20T11:42:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/77/53/c9c16f814597303e4373eb3cdfc8b0ca75582f6939a1a84b9109c98cbfeb/granian-2.8.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:217c38e88aa324ef73c9c5d823434b5c5ee7a7181b36c23b408d439905ece7a9", size = 4658203, upload-time = "2026-10-20T11:42:05.252Z" }, + { url = "https://files.pythonhosted.org/packages/26/7e/9a4aebcc1fe18d849b79cbf2aded21e8d6023436b98a53b1514955c5470a/granian-2.8.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66f0de2cb17109da7ca070a2898e4543083914c2c440ba4a134a53cf4ec526be", size = 5067921, upload-time = "2026-10-20T11:42:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4b/d7bb8f98d739e62379d88131654ee68de441ccc5d6118f578784689257fe/granian-2.8.5-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:95bae2039b53bc532386a0b1327b52e73c16b3e11997a727024f9bd385242a2b", size = 5117522, upload-time = "2026-10-20T11:42:07.749Z" }, + { url = "https://files.pythonhosted.org/packages/59/3e/2c494c6afa6c4ae564c05d0611ce7ee108824812a74bb20e024e752a8f2c/granian-2.8.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1c72079469aba9856d4d030b21ff75a5c2d9a201cab930926237ab1646e5596f", size = 5141659, upload-time = "2026-10-20T11:42:08.96Z" }, + { url = "https://files.pythonhosted.org/packages/5a/16/047f981331408110441c5adbdf9a21acd34846e13ee6546e77c7359b760a/granian-2.8.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d263fe13e16a612a0149e6948f556e5ed8fb4fdec9890812743d116025fb442", size = 5210772, upload-time = "2026-10-20T11:42:10.818Z" }, + { url = "https://files.pythonhosted.org/packages/3f/21/40f73b96a0fe0bcdea7a65ede042a029a10c1490a513fbf8909087eab02c/granian-2.8.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:19902f90f272c089ecea998214c62f7eae846d454a9a82458fcb77b41adddd29", size = 5157211, upload-time = "2026-10-20T11:42:12.484Z" }, + { url = "https://files.pythonhosted.org/packages/14/a5/ac92e100d1f5141115ee796321d8f9d25d8f2c07198e5763b1ce53c457bc/granian-2.8.5-cp312-cp312-win_amd64.whl", hash = "sha256:efc2ae72102983a32cf6cc524a595fae3211937bdeb5bf93767b01dc6798f8ea", size = 3212050, upload-time = "2026-10-20T11:42:13.894Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9e/c831afbdc6306b4c05ea89619b3e51d3350d499ab3a3078f42b3e1481a6f/granian-2.8.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:c30bf8ad2a9547b8efe4cffa67adef5d3f065cac7b37ff7ca6f7df12ed07bfe8", size = 4600095, upload-time = "2026-10-20T11:42:15.316Z" }, + { url = "https://files.pythonhosted.org/packages/29/d0/66c795135b0dc79f0a1e14fdbfc9c1d01d88ef7ceafa651185ef6f9d3e87/granian-2.8.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4ea263ab8c64434080269a49dc706a1841f7939ceaf1ddec0f204edbab2d83fc", size = 4305333, upload-time = "2026-10-20T11:42:16.815Z" }, + { url = "https://files.pythonhosted.org/packages/1f/3d/1b32e5d497d413903c5c58dded3751b8588ea8282d4c61ca57d694408fb9/granian-2.8.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:048feae9762a044357ed7e31b9117f646ca985b5cd57383120f93b2d3da5bb63", size = 5114313, upload-time = "2026-10-20T11:42:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/0d/94/063998b729003dab2a7a793a3deb4fec23581aa0a51f4a5e492a5fab194b/granian-2.8.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87eca4dbf606eb4a91f1c01f7cf7517c8290343cc083274a9e6d24d7b20577c1", size = 4599262, upload-time = "2026-10-20T11:42:19.529Z" }, + { url = "https://files.pythonhosted.org/packages/2c/00/c3db2af29473d37252a1629f9512c45479da3f4efe542a8ec07ebed24108/granian-2.8.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf44d5c35995f6009307d43c6b7c8310299f07f415d6cbecbacc5fdf8508c73f", size = 5065166, upload-time = "2026-10-20T11:42:20.995Z" }, + { url = "https://files.pythonhosted.org/packages/64/26/05d2bb66e8a6c07568a6ae334b7a5e9c674462ddc88cdcb9fc5a14e2b5cf/granian-2.8.5-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:30c85664d3a51397c97413f5c341e7031ea08614c016d9bfb2ee82bbd6029c12", size = 5114096, upload-time = "2026-10-20T11:42:22.261Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ca/41e56be77bcd77b4bab356302dc6b7755227ab33645185131ae3a40b1331/granian-2.8.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1be90ad793a14e367c3cc8a21aa82078b4a6e84df95700e3fd76f4207e558b3f", size = 5141240, upload-time = "2026-10-20T11:42:23.492Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a8/cd89ab5a87ff71a3f6867fa9ec931a27810bb8e7da6b24d41288476a8f70/granian-2.8.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:9c84d73e53c774d8600d95c9eda1904ac72c3ce761407f4b41af20ce9a0b2859", size = 5172814, upload-time = "2026-10-20T11:42:24.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/56/baf53317b674e5a8b8aeb1d188be42dae67982301da140b6214b448d2f7d/granian-2.8.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f66fd2c88fc3f9bed832df754dc4c05d685d5254fec54f77abd813ac28433b2e", size = 5155624, upload-time = "2026-10-20T11:42:26.048Z" }, + { url = "https://files.pythonhosted.org/packages/9b/4b/d3fab8903e19053a1a877d12f61c9574f4eb22657ca8d16fa8dc8d66621f/granian-2.8.5-cp313-cp313-win_amd64.whl", hash = "sha256:34f5eba9ee539a45f83cfeeda5d21b84da165db1482c7be2525bca689eb37871", size = 3240628, upload-time = "2026-10-20T11:42:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/94/6c/44290c79496b69cdc68c1438428cd8207de00a8b14bbf45aca59273f1b7d/granian-2.8.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6a2de1f78f4b54193f37f6c89562b9fd372dc68812e0f4aa82499770631ef6a3", size = 4575722, upload-time = "2026-10-20T11:42:28.764Z" }, + { url = "https://files.pythonhosted.org/packages/09/09/5b1e9d5ee47ca8e248ed4d8d41f0290a312c1c2d9415f558ae0106a6691a/granian-2.8.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0ba7323572d6f0b6cc1a86fac8f6bc42d467fe0034c85429252e93b111c79a1f", size = 4292154, upload-time = "2026-10-20T11:42:30.142Z" }, + { url = "https://files.pythonhosted.org/packages/2c/75/8e8ab830eb7d3bbeb5147bdd5e05e7f307f46950647e0277973811ce511c/granian-2.8.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cdf207b3942b0c131f98cd01769b2e1e8f302891b220b39140c8d4a325d3af5d", size = 5111113, upload-time = "2026-10-20T11:42:31.924Z" }, + { url = "https://files.pythonhosted.org/packages/77/b4/1b3c164cdb8c16c130fa483ab15b18c4f068406e901b1bf30725ab2dca3b/granian-2.8.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a25ee45ab230a9983c6ba6401b1dbc7b3ee7de48793c52712c8ffb9662baefce", size = 4598387, upload-time = "2026-10-20T11:42:33.386Z" }, + { url = "https://files.pythonhosted.org/packages/c4/65/70d500aaf815be539d2761192b499fc7514a625eb92b1f55c4639db7d75c/granian-2.8.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8162358380d4b9398a2176779cefe553921fd5e433ccad7fe6d371e0845fe82", size = 5043379, upload-time = "2026-10-20T11:42:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b0/863f62924357de8b486f22a7a5b11e7bda2dbebcc44ce2950af210f3d57b/granian-2.8.5-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:091fa616adfbd83460433a99df0858ff3e831e937e478d1e8170a0fb71c61431", size = 5104873, upload-time = "2026-10-20T11:42:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/cc/19/4214986ccad3b6791b5b70ee8ee1545af4a12c8650726025280f69854655/granian-2.8.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:66f2ebe92dabfd986d99cb911c1027bacb35570119e244918e5beeef56dce1e3", size = 5133431, upload-time = "2026-10-20T11:42:37.506Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/fbeb8760e5854115d0573a998d2239d844f3681781f3d2ab1b732fc05224/granian-2.8.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:519d1461c2ea4086ff5643b8abaa78dadc5a3d0f2ab5ac4de1811f9980c3e153", size = 5168722, upload-time = "2026-10-20T11:42:38.779Z" }, + { url = "https://files.pythonhosted.org/packages/d1/72/063abeff9026aa62aa1412b327cb4b75b161dec5744e60b24eba6c98bbd7/granian-2.8.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2049b3b0d591c754ac89e313f88e8dd35c4aa741193e39f2329eece4e447c04b", size = 5130161, upload-time = "2026-10-20T11:42:40.216Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/fd9cee9460989df1caa497bfebe4cbe7d98d2de013e8ca92594e8b15d4a0/granian-2.8.5-cp314-cp314-win_amd64.whl", hash = "sha256:14d92199bf255db7ce64a63a63f42d52392f287c097a0427e60436111a7853a7", size = 3233798, upload-time = "2026-10-20T11:42:41.451Z" }, + { url = "https://files.pythonhosted.org/packages/fa/cd/a56c529ae01d5ebf68971a4e741844274d1565ac0d2e05ef8f2cb76ff878/granian-2.8.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9ae77ceee1148982c9d3f7ded52961355c81adae206e9621fe27c9a7c426a260", size = 4495468, upload-time = "2026-10-20T11:42:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ff/6c86c7cc796c440c383300aaf3c95736e5663a756a8164b4faa6ed0be2a9/granian-2.8.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3ab0046f99ccbb39198ecadac6df163ff312886b93630e1ca319a1fafecf6af4", size = 4176464, upload-time = "2026-10-20T11:42:44.035Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d4/6211c9839cf93b675221d388e4eb14e1eb1f3826f694b9d4b872d2a5f0af/granian-2.8.5-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:83ece3459a3652a6389ee663efdb39b5c5c27ebf349efdff0cbe4c6dcb7cbc9c", size = 4337940, upload-time = "2026-10-20T11:42:45.577Z" }, + { url = "https://files.pythonhosted.org/packages/05/fa/a4061b90996d5b6d8868d71b40a75f5d52618e4c8f59b5adf8d40bf420d9/granian-2.8.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2fd8a7956acfbd8b223e39103d63f80e6106d30fc9dc2f913eb3a638bc5c57", size = 5011790, upload-time = "2026-10-20T11:42:47.238Z" }, + { url = "https://files.pythonhosted.org/packages/27/b8/63070894c66028a35820d4d0c7a7ab976f74cc2c0e00cdbd1f60f79026ac/granian-2.8.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67a0fbdca1f7e923d1627b584a1f0dab66b7a18227acfd583ecdaeeff67fa4e", size = 4854816, upload-time = "2026-10-20T11:42:48.526Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d9/2f6e76dff34f81eaa4c45de9fbc86f5468f5a88e1224250c506a338bbf2a/granian-2.8.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:8efb3ab7e55f48c83695cc6cb196f56bc54874c45d2229e21c859135a4837788", size = 4884836, upload-time = "2026-10-20T11:42:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/06/5c/a9459e7561b233420baec9dde2bdd1f76f9c1e026ebd51570b8f99013a6c/granian-2.8.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:91bdc54ddee28d5b809533937a086a2e1dc78e0b746b7d925785863036d815c6", size = 5082247, upload-time = "2026-10-20T11:42:51.343Z" }, + { url = "https://files.pythonhosted.org/packages/75/29/afa188c5f28b0d9ec09eda9016fa99e2a44ecd9ea399b3c3fa67f399300b/granian-2.8.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:9f9fbcfb98e19f649f70e12c432609c4d03eaf61e14b6e2093abde7042f9c533", size = 5053807, upload-time = "2026-10-20T11:42:52.739Z" }, + { url = "https://files.pythonhosted.org/packages/94/ff/b36c8988e56a2b3e7846fc63e5c93a15408d96786807427533fed87c49e6/granian-2.8.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c961cb4b17dc58bafa8a0c02a7be988c81f69fce76b5ecfe20c656fda0ede22", size = 5095841, upload-time = "2026-10-20T11:42:54.325Z" }, + { url = "https://files.pythonhosted.org/packages/3d/9d/c25f5eaa94f5e57238c23686531833bbd5c9dc2896068f8b95f8faf860e8/granian-2.8.5-cp314-cp314t-win_amd64.whl", hash = "sha256:3012b24c51882581c6717f77f907e9ea2e8178a9f5170a4eaabcb1b67a059adf", size = 3166239, upload-time = "2026-10-20T11:42:55.75Z" }, + { url = "https://files.pythonhosted.org/packages/98/7e/7fb5e711fa2e973691175cd7629ad3ad2268359489cc00c928ee7326ce30/granian-2.8.5-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:4d7d369665b628aca1e9aead5942e017f796ddf1d894aae775b61430a76b8c34", size = 4575846, upload-time = "2026-10-20T11:42:57.195Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/30313d9f420871f17937f460356a37161f0749ef4bf00f1e55a206845644/granian-2.8.5-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:5efdd2240bfa3c73b28823b600a536435e668c319b563056169dc2ccc8455c00", size = 4289515, upload-time = "2026-10-20T11:42:58.922Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/faebf1a75105cbb80fb5aaf10b0e847b5a3c95be4d1ae418574e0b9f471c/granian-2.8.5-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4643e7e06e41c5e6fd3f64120351b82f0355cac2fc55753b416d4ccfdcf3b096", size = 5107846, upload-time = "2026-10-20T11:43:00.087Z" }, + { url = "https://files.pythonhosted.org/packages/77/aa/74a3daefc65bae63aa69e83304ebc40b71f0cce697084f57ec08475062b4/granian-2.8.5-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f88a2287bd249e5412c48389eb8ef7abd402e224cae91a991c69038c05168a15", size = 4597929, upload-time = "2026-10-20T11:43:01.693Z" }, + { url = "https://files.pythonhosted.org/packages/82/7e/357ae2afd73f26f66afb10d5f56a05964034479202c6dcee0625acf11f0a/granian-2.8.5-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4dd03483d79831d9e67c3490aa4d68e717bbf68b15fd84d75d05413b341764", size = 5043750, upload-time = "2026-10-20T11:43:02.975Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ae/5a2323c84244954ea1f57cff8839b21dc160fc8867595ad02d14047c4e14/granian-2.8.5-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:1eefe87b1d4294e6c5f0876f5f497af6ecca6b1ebd79a4a841cbdc9469981781", size = 5105156, upload-time = "2026-10-20T11:43:04.628Z" }, + { url = "https://files.pythonhosted.org/packages/40/6d/842f6ba44ef4c8eb58487de3b80e712c2863e79053272ad6dc6936b4052f/granian-2.8.5-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:bc75eabd606b76dbe31f2641d006152b27e8f317336faf18eb5ebe68e408b23f", size = 5135072, upload-time = "2026-10-20T11:43:05.938Z" }, + { url = "https://files.pythonhosted.org/packages/df/4c/413a653dff737322f37aa58bff198624a19a10bd949d269ae3851c98e296/granian-2.8.5-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:9db4955fdddcbf975cb6f5aa97862679647ead741ecdd08963b5fc8b8d4a3ec5", size = 5170126, upload-time = "2026-10-20T11:43:07.332Z" }, + { url = "https://files.pythonhosted.org/packages/65/2a/d2faa15f0e8dfce88abaa607946219dad89ace7c0c0654e77157db65ed24/granian-2.8.5-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:cf3ecf3a3244b9131ce0a847e50fa404b340a99b0904047d993eef0ed4cc5f4f", size = 5130094, upload-time = "2026-10-20T11:43:08.636Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f4/79b63eabb67cd36b43b98fa6d9a1a8ee95b8a01eadab64fbf9cfdfabd148/granian-2.8.5-cp315-cp315-win_amd64.whl", hash = "sha256:e23bcb230aaf0c15d8e8720f7950a7d4048d73e50032c17e5db6076af5dbbc69", size = 3233682, upload-time = "2026-10-20T11:43:10.701Z" }, + { url = "https://files.pythonhosted.org/packages/ee/35/6cb05558f4c3ceb75e8e5902450aa6ad40b11241684ef47cc9e2483584d0/granian-2.8.5-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:08b00072451bfc29cdd0dfef1066dbb8f9e9ca9d260b223f82c79d1168a194eb", size = 4494355, upload-time = "2026-10-20T11:43:12.011Z" }, + { url = "https://files.pythonhosted.org/packages/04/43/24df4c606be4d15dfca92a5ce3ee4b8f4fcf5327519af4d3c403a5b2fe4d/granian-2.8.5-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b18d114a56029d464ff2f782387b906b180177462d9d2254f4f73dda829c4ad3", size = 4176714, upload-time = "2026-10-20T11:43:13.843Z" }, + { url = "https://files.pythonhosted.org/packages/99/ef/7ad359c036d88ae4801360bf5350e9976a9c662e2479964b96ba182463bf/granian-2.8.5-cp315-cp315t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:ea673c5c98fd182138ed21d0a05528a11bd469e201f557a7b59bb8062b5b37d6", size = 4340682, upload-time = "2026-10-20T11:43:15.085Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2c/e23041ec7f44022fe9e5ffdc927008de62bc83c33eb8d3f3a6151a15a8bc/granian-2.8.5-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7aa3d26e71794cb7767b0909d92d76736b14aa0fdd451b94b6a0d4d0c875d403", size = 5012489, upload-time = "2026-10-20T11:43:16.573Z" }, + { url = "https://files.pythonhosted.org/packages/ce/95/7b9ba12477bd6c8fd34261c9586f303a9dc98928a15d8b05a0e50ced47a2/granian-2.8.5-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62fe53469efaf497f84a822537470fe8702df8125b0247ee38415e4684ee1c91", size = 4854972, upload-time = "2026-10-20T11:43:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/da/f4/b4d469cfdb0ca72e8990e4dd3183e668904f5c2e42cbda911c95bc502eb0/granian-2.8.5-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:fd39d21c95d49dd5be07b6e3031acef2590a0ef41218a38fca489700c7e05847", size = 4884122, upload-time = "2026-10-20T11:43:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/55/c8/14db3b06e52ddf74b8b50e70b0ce7d8929aed24a828d35fc8bba7cf4cae3/granian-2.8.5-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:deb8d3bfb1ebefd8eaabbb2b25bb533f74a3e453bcaa419177e59d39682710e3", size = 5083706, upload-time = "2026-10-20T11:43:20.575Z" }, + { url = "https://files.pythonhosted.org/packages/d6/9e/4fcf222551e36358c66c6b971e0b2149ba8a0adb5ef99d0168033b84505d/granian-2.8.5-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:58713e4fd8b3ee5d51b5d1aacc2f800b6922dd5f19cd2609dbbcc47336bc6b7d", size = 5055522, upload-time = "2026-10-20T11:43:21.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/5c/6e1b5d5b498fae263145fe719d053810b38d86d754a92454d15a985274d2/granian-2.8.5-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:42aa50fcf6798f30bec90f9b5a22d3039be06932423b12ffa80b1ade2328a631", size = 5096223, upload-time = "2026-10-20T11:43:23.238Z" }, + { url = "https://files.pythonhosted.org/packages/72/ba/8f76547c8c52eed6ac2fe2b17e749b7fb8bef9bc23dcfd849897bc7ae7b1/granian-2.8.5-cp315-cp315t-win_amd64.whl", hash = "sha256:1fb135bb48bef82f9dcaef3462c4b49b24a6a423572c65e6b9db4647555b821e", size = 3166220, upload-time = "2026-10-20T11:43:24.509Z" }, + { url = "https://files.pythonhosted.org/packages/89/c9/751496c5332fe7b6d1775d7342f3856ba5a7b32b978c5a083d11a9025df0/granian-2.8.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c7787e67de0aa08cf7ca24fdcb775cd03001d0240212a1294f73bf09356d40ba", size = 4586917, upload-time = "2026-10-20T11:43:25.967Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f1/d419875fe9fac243324dbc949e7b3dac63a70a54bb205f56a2d057e18765/granian-2.8.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1915e09e4eb6cf3d5134633c5302e6525529b929d549a3861df011d4044f3706", size = 4257626, upload-time = "2026-10-20T11:43:27.845Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/520fe7f10a527b72294e6714370943fcd9d003da7c940c4c9695ca36943a/granian-2.8.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41017d36448de63a171c76e9e728bdad0645ca1f8a4b2a62267c61dedf13c19f", size = 5028778, upload-time = "2026-10-20T11:43:29.286Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c7/8c25aef4cde87871414eebd0ab081141af1645f0514a4179ded6d3555082/granian-2.8.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:93d8e1ebaa7af1599437a7e7bd627249374a4267ed4aad7ce08feabeea25bdbc", size = 5048930, upload-time = "2026-10-20T11:43:30.824Z" }, + { url = "https://files.pythonhosted.org/packages/d0/23/2ddcc44baabd39c32714cd3b868a1d27c6ae318a2bfac879a8241a8ac083/granian-2.8.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:8c052f5399c31eb5b08d6e67232cae69070341acb5e6d19b5aa0b45bed48f6c6", size = 5075940, upload-time = "2026-10-20T11:43:32.601Z" }, + { url = "https://files.pythonhosted.org/packages/16/24/24ae014a30c5eb98afbf88d75fe066a4cb803379e588247730966c9cdf7a/granian-2.8.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:fa4caf085a082fbc192bafe8f5bce37673b82258b56399520cf7203217eea48a", size = 5165526, upload-time = "2026-10-20T11:43:33.845Z" }, + { url = "https://files.pythonhosted.org/packages/d1/57/958e7f82de6947f95b6248c2ec2cc9979858f5f8a2ba0dd98665dfaa4c10/granian-2.8.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:a51bc4b6a6c7692037bfa2a55969714318bfb72cab1901280829333be21899e7", size = 5123288, upload-time = "2026-10-20T11:43:35.321Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3e/60d2c5b2e696fe59b4922bdb96d69661f073c60eb52519790e378593af5a/granian-2.8.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c6803546881c0a2e4ad21088fabdb8ef0f802c3edf5d1606cbd8b7146b7345bc", size = 3222429, upload-time = "2026-10-20T11:43:36.661Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "invoke" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/f6/227c48c5fe47fa178ccf1fda8f047d16c97ba926567b661e9ce2045c600c/invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c", size = 343419, upload-time = "2026-04-07T15:17:48.307Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/de/bbc12563bbf979618d17625a4e753ff7a078523e28d870d3626daa97261a/invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053", size = 160958, upload-time = "2026-04-07T15:17:46.875Z" }, +] + +[[package]] +name = "iso8601" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/f3/ef59cee614d5e0accf6fd0cbba025b93b272e626ca89fb70a3e9187c5d15/iso8601-2.1.0.tar.gz", hash = "sha256:6b1d3829ee8921c4301998c909f7829fa9ed3cbdac0d3b16af2d743aed1ba8df", size = 6522, upload-time = "2023-10-03T00:25:39.317Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/0c/f37b6a241f0759b7653ffa7213889d89ad49a2b76eb2ddf3b57b2738c347/iso8601-2.1.0-py3-none-any.whl", hash = "sha256:aac4145c4dcb66ad8b648a02830f5e2ff6c24af20f4f482689be402db2429242", size = 7545, upload-time = "2023-10-03T00:25:32.304Z" }, +] + +[[package]] +name = "kaya-core" +version = "0.0.8" +source = { registry = "https://gitea.woggioni.net/api/packages/woggioni/pypi/simple" } +dependencies = [ + { name = "pwo" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/kaya-core/0.0.8/kaya_core-0.0.8.tar.gz", hash = "sha256:b392dd825ae38caaa4d62c7259de0370af62055a9f72532f088e19bce0791c7e" } +wheels = [ + { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/kaya-core/0.0.8/kaya_core-0.0.8-py3-none-any.whl", hash = "sha256:bce5b9057f1d983d974048738ecee6e2717cec384befd5cae01eed72e455fffa" }, +] + +[[package]] +name = "kaya-cors" +version = "0.0.8" +source = { registry = "https://gitea.woggioni.net/api/packages/woggioni/pypi/simple" } +dependencies = [ + { name = "kaya-core" }, +] +sdist = { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/kaya-cors/0.0.8/kaya_cors-0.0.8.tar.gz", hash = "sha256:7ca6b1fc39de58b67b735ce09b71753e406e5212993132088089c2b2b6de67ed" } +wheels = [ + { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/kaya-cors/0.0.8/kaya_cors-0.0.8-py3-none-any.whl", hash = "sha256:4a8c3be520f5836dcbe44d848ac2a6d29ef3c044def400818a7207c47d116363" }, +] + +[[package]] +name = "kaya-oidc" +version = "0.0.8" +source = { registry = "https://gitea.woggioni.net/api/packages/woggioni/pypi/simple" } +dependencies = [ + { name = "httpx" }, + { name = "kaya-core" }, + { name = "kaya-session" }, + { name = "pyjwt", extra = ["crypto"] }, +] +sdist = { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/kaya-oidc/0.0.8/kaya_oidc-0.0.8.tar.gz", hash = "sha256:cbef490f6b6c990af002c40f4dcd1a485353c4955a622756e4e60ad3c37b3471" } +wheels = [ + { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/kaya-oidc/0.0.8/kaya_oidc-0.0.8-py3-none-any.whl", hash = "sha256:6f01e2dbe3301d0e8798ae5d588b71dae2de84d8ea63c4dc0b6141c683718d60" }, +] + +[[package]] +name = "kaya-openapi" +version = "0.0.8" +source = { registry = "https://gitea.woggioni.net/api/packages/woggioni/pypi/simple" } +dependencies = [ + { name = "kaya-core" }, +] +sdist = { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/kaya-openapi/0.0.8/kaya_openapi-0.0.8.tar.gz", hash = "sha256:4fda8ac42b6009c454e435356b629ecafc2e14de19a3c02d4923d81458e232b2" } +wheels = [ + { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/kaya-openapi/0.0.8/kaya_openapi-0.0.8-py3-none-any.whl", hash = "sha256:eda4efa755f0b7815a61ce1a7dc7722d506f76660a96a8a9eaa375f42f1dc8a7" }, +] + +[[package]] +name = "kaya-otel" +version = "0.0.8" +source = { registry = "https://gitea.woggioni.net/api/packages/woggioni/pypi/simple" } +dependencies = [ + { name = "kaya-core" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/kaya-otel/0.0.8/kaya_otel-0.0.8.tar.gz", hash = "sha256:f51cb22e143bd43d9680ba2489a201915b76fa64eebc0712b71ad4559da98d1e" } +wheels = [ + { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/kaya-otel/0.0.8/kaya_otel-0.0.8-py3-none-any.whl", hash = "sha256:19444f79a2421f6416a0f72d39a96a823146e03a591525bcba0254d3bc08fc14" }, +] + +[[package]] +name = "kaya-rsgi" +version = "0.0.8" +source = { registry = "https://gitea.woggioni.net/api/packages/woggioni/pypi/simple" } +dependencies = [ + { name = "granian" }, + { name = "kaya-core" }, + { name = "pwo" }, +] +sdist = { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/kaya-rsgi/0.0.8/kaya_rsgi-0.0.8.tar.gz", hash = "sha256:82b1092c9bcdf406416144eef1c0b309f7ebd2be4cb137ee554418b1cdcac3ac" } +wheels = [ + { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/kaya-rsgi/0.0.8/kaya_rsgi-0.0.8-py3-none-any.whl", hash = "sha256:fdaa1561f33852d7442b4639235e182f88db5bc51aa1d6cac4ed0d30446951a4" }, +] + +[[package]] +name = "kaya-session" +version = "0.0.8" +source = { registry = "https://gitea.woggioni.net/api/packages/woggioni/pypi/simple" } +dependencies = [ + { name = "kaya-core" }, + { name = "pwo" }, +] +sdist = { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/kaya-session/0.0.8/kaya_session-0.0.8.tar.gz", hash = "sha256:1429a2ead7de30459d7f90010bab0c916db2cc1b61d5112bcbd9b27503ed549e" } +wheels = [ + { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/kaya-session/0.0.8/kaya_session-0.0.8-py3-none-any.whl", hash = "sha256:38ec624e6f6272b6d284492ab315ae26665bbe2aee212e18be7adce4e24a3dbb" }, +] + +[[package]] +name = "kaya-session-redis" +version = "0.0.8" +source = { registry = "https://gitea.woggioni.net/api/packages/woggioni/pypi/simple" } +dependencies = [ + { name = "kaya-session" }, + { name = "redis" }, +] +sdist = { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/kaya-session-redis/0.0.8/kaya_session_redis-0.0.8.tar.gz", hash = "sha256:602120e3f4f0820effe062de7b7efdc8460cb91710632d47b7f82e7628e415de" } +wheels = [ + { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/kaya-session-redis/0.0.8/kaya_session_redis-0.0.8-py3-none-any.whl", hash = "sha256:1c8f2ef2b8e687a8d5caeeb39009b604f86f2b50eba113d49583857bd39d0810" }, +] + +[[package]] +name = "kca-app" +version = "0.1.0" +source = { editable = "packages/kca-app" } +dependencies = [ + { name = "aerich" }, + { name = "asyncpg" }, + { name = "cryptography" }, + { name = "granian" }, + { name = "httpx" }, + { name = "kaya-core" }, + { name = "kaya-cors" }, + { name = "kaya-oidc" }, + { name = "kaya-openapi" }, + { name = "kaya-rsgi" }, + { name = "kaya-session" }, + { name = "kaya-session-redis" }, + { name = "kca-core" }, + { name = "paramiko" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "pyopenssl" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "tortoise-orm" }, +] + +[package.optional-dependencies] +otel = [ + { name = "kaya-otel" }, + { name = "opentelemetry-instrumentation-asyncpg" }, + { name = "opentelemetry-instrumentation-redis" }, + { name = "opentelemetry-instrumentation-system-metrics" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy" }, + { name = "types-pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "aerich" }, + { name = "asyncpg" }, + { name = "cryptography", specifier = ">=44" }, + { name = "granian", specifier = ">=2.0" }, + { name = "httpx" }, + { name = "kaya-core", specifier = ">=0.0.7" }, + { name = "kaya-cors" }, + { name = "kaya-oidc", specifier = ">=0.0.7" }, + { name = "kaya-openapi" }, + { name = "kaya-otel", marker = "extra == 'otel'", specifier = ">=0.0.7" }, + { name = "kaya-rsgi" }, + { name = "kaya-session" }, + { name = "kaya-session-redis" }, + { name = "kca-core", editable = "packages/kca-core" }, + { name = "opentelemetry-instrumentation-asyncpg", marker = "extra == 'otel'", specifier = ">=0.66b0" }, + { name = "opentelemetry-instrumentation-redis", marker = "extra == 'otel'", specifier = ">=0.66b0" }, + { name = "opentelemetry-instrumentation-system-metrics", marker = "extra == 'otel'", specifier = ">=0.66b0" }, + { name = "paramiko", specifier = ">=3.5" }, + { name = "pyjwt", extras = ["crypto"] }, + { name = "pyopenssl", specifier = ">=25" }, + { name = "pyyaml" }, + { name = "redis" }, + { name = "tortoise-orm", specifier = ">=0.25" }, +] +provides-extras = ["otel"] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy" }, + { name = "types-pyyaml" }, +] + +[[package]] +name = "kca-core" +version = "0.1.0" +source = { editable = "packages/kca-core" } +dependencies = [ + { name = "cryptography" }, + { name = "paramiko" }, +] + +[package.metadata] +requires-dist = [ + { name = "cryptography", specifier = ">=44" }, + { name = "paramiko", specifier = ">=3.5" }, +] + +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839, upload-time = "2026-09-29T00:55:32.891Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/77/900294b3c9a81415323d81b3f3f271a7f5d7ed9d01e824fc38ffa49470b6/librt-0.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:96f576f2711f8519152ec76d0e599243555c1f07679fa73606ca8c8c868c0be6", size = 148668, upload-time = "2026-09-29T00:44:20.282Z" }, + { url = "https://files.pythonhosted.org/packages/55/3b/c301e8cc47758e4518b93e926a0111080155cc79fa03651d202ac2025065/librt-0.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2bec3818c7da7c96ceae0ef5915a3d16c52dd08f3ea913bf1fe8568c447c7978", size = 153562, upload-time = "2026-09-29T00:44:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/62/b1/4d5b98114a58bf17e3832cd33400e40f2582f68c2fb7ce25b4468cb1afd3/librt-0.16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c79520414a3fdfc6aabd7593e6169afa14d5f8d9908d4b498db068868b08dd", size = 494337, upload-time = "2026-09-29T00:44:23.096Z" }, + { url = "https://files.pythonhosted.org/packages/88/36/14223c9b0dc38982eb4f399732c8cb27b19c7e027ae0faef5ca51d08d19f/librt-0.16.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f81b5b19ce748ef68d4746656b7929762eb2fe99269b266e4be07e2ee4de7144", size = 485440, upload-time = "2026-09-29T00:44:24.555Z" }, + { url = "https://files.pythonhosted.org/packages/ec/9e/d7155e6800d0c33e297e529b217649f7361d706491ebf9d2fc0fc627309e/librt-0.16.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c71d1b76210a36729fedfc5115069b50a3d8619054745f8758fe5d6f19e86671", size = 498363, upload-time = "2026-09-29T00:44:26.03Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ce/d5f1fb9f913ac0979d6fc5db9fb9c0bf28cc3470988528957922f15ed40c/librt-0.16.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:473eebc7866bb0a0c8849a292b5e7157c1aba5d14d0f0f610c52158d6d964262", size = 515453, upload-time = "2026-09-29T00:44:27.566Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/ca0fca1a27c26080d1a25b2b31f271e532078a5719878c3daca0c3d4c539/librt-0.16.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4eb1313a19847089ee81e88742abedf285c60538816640b99742d8534b81d26a", size = 509537, upload-time = "2026-09-29T00:44:29.14Z" }, + { url = "https://files.pythonhosted.org/packages/ee/c0/26807bf2221a53a2c66f2e23a5c4d57f1c0eaa4afed7cb81d841cf3d1903/librt-0.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ebefd60b42e2a82b32d136bb5f7c94eadfcd29f772b547df6f3291d1ed855a1c", size = 532541, upload-time = "2026-09-29T00:44:30.745Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/c329d509907e1bc061abeec3796f94d255d43ba25d28c3854d2138ed5ab2/librt-0.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c5da27e8056439f927ea896735da60e616c477a8293feaa3233d4e7781a6726", size = 537059, upload-time = "2026-09-29T00:44:32.102Z" }, + { url = "https://files.pythonhosted.org/packages/84/d6/4571b9c9e65d98e5503734166dce2fd3e216f526dc3c59a3773faa8a60e3/librt-0.16.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:67e718c7a43f8db325abbbf1404e2d535f12f8f7a1a82259568385cc5274b82a", size = 536614, upload-time = "2026-09-29T00:44:33.653Z" }, + { url = "https://files.pythonhosted.org/packages/c8/d3/a2ed8a292e4ab7889792a9c3f4ad5d1caf6557afce7f6b452e77f8ed20b7/librt-0.16.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:314e703f0c19320dc8094e7a784b9cf29e1b67402515abf580069a6363c0b4f1", size = 517199, upload-time = "2026-09-29T00:44:35.115Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/276e5218b4b7adb6258a42b7403cb1b7cef25616238d0c2152288a9d2f53/librt-0.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:943c6bbecbdf7fa575a4f2952fcfd848c88ef95507c3fca411e89d4ac3ff8143", size = 558746, upload-time = "2026-09-29T00:44:36.656Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5c/9156df81a1726e1efc9567379def2b8777a6d56f464d2f1b760bf951b8af/librt-0.16.0-cp310-cp310-win32.whl", hash = "sha256:7cc365f006891afb006b52d5ee5ee74c09306ffa20e2f8705a32a4450af2f3ba", size = 104424, upload-time = "2026-09-29T00:44:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/0b/62/4fd172cb3f49ab2cf5ef17de519a93cfb935202c1270ba9d44c6a64178c3/librt-0.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:0314058469f4d2fd279ce7c62ac274ac82c3918ef7db62ef0697c4c359370155", size = 124267, upload-time = "2026-09-29T00:44:39.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058, upload-time = "2026-09-29T00:44:40.903Z" }, + { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073, upload-time = "2026-09-29T00:44:42.165Z" }, + { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052, upload-time = "2026-09-29T00:44:44.043Z" }, + { url = "https://files.pythonhosted.org/packages/94/42/aaa663bc9421f5bf7bbbe9d3316d7219749698ec7688ac3b519770a89c04/librt-0.16.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b", size = 485506, upload-time = "2026-09-29T00:44:45.479Z" }, + { url = "https://files.pythonhosted.org/packages/7b/59/83a6eb5087ba8b45b083859e2085256963e06d4c2531944cdd962670bfb5/librt-0.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071", size = 498708, upload-time = "2026-09-29T00:44:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/15a8d62b2bc12935080ec7277e2e0b9b233047781eb784a51d3e25af669a/librt-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602", size = 515981, upload-time = "2026-09-29T00:44:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/4f/287cc282ac5dd815204c0779e79401bb0a568c3a1335fb319bf0838fb566/librt-0.16.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d", size = 508670, upload-time = "2026-09-29T00:44:49.726Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5b/ab7292add898ade0d2b5b75d32a923aae315861671aa5c39f5491309f7ae/librt-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e", size = 530393, upload-time = "2026-09-29T00:44:51.356Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/8e6bcf027d4bcbc16a4e864b3d158a1d3e5f55ffa53af0238020cad89271/librt-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1", size = 534302, upload-time = "2026-09-29T00:44:52.893Z" }, + { url = "https://files.pythonhosted.org/packages/96/a4/8c5ab4f3b3a8e905ff8305d76976a2e666f52fe049edd398aed2402beb36/librt-0.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905", size = 536150, upload-time = "2026-09-29T00:44:54.421Z" }, + { url = "https://files.pythonhosted.org/packages/ea/5e/7c4bc267e3cd4b8bd288de9f62d61fff203829c9cc649ea2aa12e9218cf0/librt-0.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e", size = 514316, upload-time = "2026-09-29T00:44:55.998Z" }, + { url = "https://files.pythonhosted.org/packages/e0/75/c8bc6b50f43c73819334233f5a3259c81216742cef56d2140c3dd377c3f0/librt-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96", size = 557731, upload-time = "2026-09-29T00:44:57.581Z" }, + { url = "https://files.pythonhosted.org/packages/93/fb/e241f91bc8de6c7ac1c6a20ea6dd381bc52312b44d711ffcd97140db1e57/librt-0.16.0-cp311-cp311-win32.whl", hash = "sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7", size = 104922, upload-time = "2026-09-29T00:44:59.078Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/c892383d2bb1a6a80b2ec8ede84970a265a792c56614a06282bf3008517c/librt-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d", size = 125093, upload-time = "2026-09-29T00:49:01.621Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/8bbde1ed0b21480163617648f638fdaec9374cf5e9c846ebcee34ec02a42/librt-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678", size = 115910, upload-time = "2026-09-29T00:52:40.131Z" }, + { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028, upload-time = "2026-09-29T00:52:41.508Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283, upload-time = "2026-09-29T00:52:42.873Z" }, + { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185, upload-time = "2026-09-29T00:52:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738, upload-time = "2026-09-29T00:52:46.441Z" }, + { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818, upload-time = "2026-09-29T00:52:48.068Z" }, + { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952, upload-time = "2026-09-29T00:52:49.816Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465, upload-time = "2026-09-29T00:52:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191, upload-time = "2026-09-29T00:52:53.284Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979, upload-time = "2026-09-29T00:52:54.932Z" }, + { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317, upload-time = "2026-09-29T00:52:56.869Z" }, + { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846, upload-time = "2026-09-29T00:52:58.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700, upload-time = "2026-09-29T00:53:00.127Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158, upload-time = "2026-09-29T00:53:02.031Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176, upload-time = "2026-09-29T00:53:03.445Z" }, + { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" }, + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036, upload-time = "2026-09-29T00:53:06.284Z" }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211, upload-time = "2026-09-29T00:53:07.72Z" }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587, upload-time = "2026-09-29T00:53:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146, upload-time = "2026-09-29T00:53:11.016Z" }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336, upload-time = "2026-09-29T00:53:12.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689, upload-time = "2026-09-29T00:53:14.39Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537, upload-time = "2026-09-29T00:53:16.064Z" }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231, upload-time = "2026-09-29T00:53:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499, upload-time = "2026-09-29T00:53:19.666Z" }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261, upload-time = "2026-09-29T00:53:21.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029, upload-time = "2026-09-29T00:53:23.07Z" }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748, upload-time = "2026-09-29T00:53:24.696Z" }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861, upload-time = "2026-09-29T00:53:26.089Z" }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236, upload-time = "2026-09-29T00:53:27.43Z" }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180, upload-time = "2026-09-29T00:53:28.7Z" }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557, upload-time = "2026-09-29T00:53:30.012Z" }, + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844, upload-time = "2026-09-29T00:53:31.391Z" }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111, upload-time = "2026-09-29T00:53:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234, upload-time = "2026-09-29T00:53:34.466Z" }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173, upload-time = "2026-09-29T00:53:36.124Z" }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547, upload-time = "2026-09-29T00:53:37.686Z" }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057, upload-time = "2026-09-29T00:53:39.301Z" }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158, upload-time = "2026-09-29T00:53:41.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183, upload-time = "2026-09-29T00:53:42.451Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663, upload-time = "2026-09-29T00:53:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908, upload-time = "2026-09-29T00:53:45.812Z" }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302, upload-time = "2026-09-29T00:53:47.596Z" }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911, upload-time = "2026-09-29T00:53:49.278Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119, upload-time = "2026-09-29T00:53:50.938Z" }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140, upload-time = "2026-09-29T00:53:52.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464, upload-time = "2026-09-29T00:53:53.495Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889, upload-time = "2026-09-29T00:53:54.817Z" }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838, upload-time = "2026-09-29T00:53:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018, upload-time = "2026-09-29T00:53:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983, upload-time = "2026-09-29T00:53:59.024Z" }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847, upload-time = "2026-09-29T00:54:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045, upload-time = "2026-09-29T00:54:02.247Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640, upload-time = "2026-09-29T00:54:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748, upload-time = "2026-09-29T00:54:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960, upload-time = "2026-09-29T00:54:07.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000, upload-time = "2026-09-29T00:54:09.06Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569, upload-time = "2026-09-29T00:54:10.702Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483, upload-time = "2026-09-29T00:54:12.25Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584, upload-time = "2026-09-29T00:54:13.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402, upload-time = "2026-09-29T00:54:15.568Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227, upload-time = "2026-09-29T00:54:17.165Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493, upload-time = "2026-09-29T00:54:18.567Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829, upload-time = "2026-09-29T00:54:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522, upload-time = "2026-09-29T00:54:21.306Z" }, + { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593, upload-time = "2026-09-29T00:54:22.798Z" }, + { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459, upload-time = "2026-09-29T00:54:24.364Z" }, + { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814, upload-time = "2026-09-29T00:54:25.997Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114, upload-time = "2026-09-29T00:54:27.747Z" }, + { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465, upload-time = "2026-09-29T00:54:29.331Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257, upload-time = "2026-09-29T00:54:30.915Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442, upload-time = "2026-09-29T00:54:32.478Z" }, + { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139, upload-time = "2026-09-29T00:54:34.183Z" }, + { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708, upload-time = "2026-09-29T00:54:35.761Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781, upload-time = "2026-09-29T00:54:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177, upload-time = "2026-09-29T00:54:38.792Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127, upload-time = "2026-09-29T00:54:40.251Z" }, + { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480, upload-time = "2026-09-29T00:54:41.722Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915, upload-time = "2026-09-29T00:54:43.052Z" }, + { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714, upload-time = "2026-09-29T00:54:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086, upload-time = "2026-09-29T00:54:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727, upload-time = "2026-09-29T00:54:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106, upload-time = "2026-09-29T00:54:49.126Z" }, + { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921, upload-time = "2026-09-29T00:54:50.905Z" }, + { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786, upload-time = "2026-09-29T00:54:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964, upload-time = "2026-09-29T00:54:54.217Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305, upload-time = "2026-09-29T00:54:55.815Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959, upload-time = "2026-09-29T00:54:57.532Z" }, + { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138, upload-time = "2026-09-29T00:54:59.484Z" }, + { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268, upload-time = "2026-09-29T00:55:01.179Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783, upload-time = "2026-09-29T00:55:02.938Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355, upload-time = "2026-09-29T00:55:04.533Z" }, + { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134, upload-time = "2026-09-29T00:55:06.017Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391, upload-time = "2026-09-29T00:55:07.484Z" }, +] + +[[package]] +name = "mypy" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425, upload-time = "2026-10-01T20:40:39.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/0a/78c9ac93933e2ecd51d807021f78c03ffe98640f33951a8be159f5fd55bc/mypy-2.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ffda5244fd1ad71a1e54405e35f50d09b80c3978efa120f58bd1252ae32c62d2", size = 14408618, upload-time = "2026-10-01T20:39:46.766Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4f/5f5f4429fd5a4218ce6268164a66afeed75c7e2ce5522a4d163faaa5fe85/mypy-2.4.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:615b03922d40e186fd1df73156473db0ba525c639bb4fd1cf28e1694879c9b23", size = 14552749, upload-time = "2026-10-01T20:40:08.325Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/fa1c319cd51db7a6027b46117460d5a361c6bba0c0f0f0670a2dab8ce2a5/mypy-2.4.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13fa24f439c0e48a290a3922fa14ccd22f2762bae99d2142931b3e40a9055080", size = 15505846, upload-time = "2026-10-01T20:39:15.475Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5a/14c02690776fb04e9fa60b81173cb9ae6315e0745fad3813b303737f2f0c/mypy-2.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3adef556a19eb3b630bf86a79c29d7da3d61e541472d0d897ca01b171c0abf8f", size = 15823059, upload-time = "2026-10-01T20:39:35.429Z" }, + { url = "https://files.pythonhosted.org/packages/76/d8/d29afa819f8cb765018d6e2773e390a41ea4592f75abe417135969646857/mypy-2.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:1dc0f64b0a92ae27a49d2175f0bacfa56e15bdc5b420cf92e0c1f79292219cbb", size = 11312927, upload-time = "2026-10-01T20:39:28.509Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c8/966dec4d80303855d824e6bf67fe1da812d88adfe4d75e24d8545dab4826/mypy-2.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:4a378fc15fb33e321f04652c166ce73eeb8833a97c3d218132844e938cd93220", size = 10879252, upload-time = "2026-10-01T20:38:41.545Z" }, + { url = "https://files.pythonhosted.org/packages/26/67/e53965e67d23d48e78dd603aa7525a8d77e7818c23954cc217ccde5cd5c0/mypy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d20e6c7c35fcbf2a0ebdd0eaeacfbc243009dfd33ab7822d54e213912e6dbbd", size = 14315745, upload-time = "2026-10-01T20:39:25.03Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5d/a588ccd57625822d8484d7aff4e9c70f88d042a91c2ec84324a2bc14b3f3/mypy-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3011537be6cf1de4511c0255a324362a812b58184bbe61e15f59c8b31033bd74", size = 14465652, upload-time = "2026-10-01T20:38:57.19Z" }, + { url = "https://files.pythonhosted.org/packages/05/af/b4978a5566cb829ca563dc08e3ad8d944f116078786ad54ba3dff2bcb64d/mypy-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a96b07a49b7b1d025ce59c1b3acbcf24bead9a83da4523c4a6bde1bb94e7a0e1", size = 15419180, upload-time = "2026-10-01T20:40:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8c/52c71f31c54d0a749d832cc73b933c6e38c52d9b270d5503e031c73723dc/mypy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7c4f8f8d1d1c0e2832d8ee7113dd08f6df6c7aad9e863fcbed9f25832be0b8c4", size = 15669212, upload-time = "2026-10-01T20:39:20.443Z" }, + { url = "https://files.pythonhosted.org/packages/e8/55/ba63121494404fd7d3787ec176ccd02820d6ec125ac4e7c4c0b79f2cc6c7/mypy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:ba05652540bf12828e52abae807b024b09ca144ff4f75e2450a81d69c376425b", size = 11299572, upload-time = "2026-10-01T20:40:03.627Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9c/385c81b5c0d3a2917f077c4da660d0493f9521ccfd4b058e33b860f72d02/mypy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:6306086b87cf7f8a29aa618d9fd9bffb56c59247166b9660fdb54d86d7714ecd", size = 10869751, upload-time = "2026-10-01T20:40:25.483Z" }, + { url = "https://files.pythonhosted.org/packages/68/ed/e5d7cf4017e74a1c1e1c4058ce8f614fc1e3e7606564f47166e22bfc9f95/mypy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e05ff2925d8b37ad26c80c1b9dc43ae5d455da2df1e23c24c095a6425917c57e", size = 14378593, upload-time = "2026-10-01T20:38:48.222Z" }, + { url = "https://files.pythonhosted.org/packages/30/7d/12d994886a922f0f1997becc9c6625198d61eeb5962558a886af7dd38d54/mypy-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29243242cf72582b65f9582ad9e56e8cb281566ed3519f4cd70bb8b9f2977e90", size = 14464697, upload-time = "2026-10-01T20:39:11.059Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9e/bcc9af755425ad17790bf11d73c2ea7592914cf309ee1340997670f9d57a/mypy-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29eb0b9427a6b11b992e452f6cceb8af724f4dceb47e779d0b35e405e996ea5e", size = 15552257, upload-time = "2026-10-01T20:39:56.269Z" }, + { url = "https://files.pythonhosted.org/packages/af/0c/3343fc4525d6f00d75ad17a93a9f052d5163641cb8911840d4a12cb59ff2/mypy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3ebe2f72a2a1156065a9851570ffbf50c0a93cdccadef9c6e05c508a4fd10b1", size = 15800443, upload-time = "2026-10-01T20:38:54.815Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0e/69aac6b8159da7c53e6115a2be1bf48e85503402cadbf506dd708e8b2ad7/mypy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:236e0d68f6941992b0811128e652590f590db444ab29ad8f1324765b9298b946", size = 11381574, upload-time = "2026-10-01T20:39:49.406Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/d32ce4feb5993eec09d2024bef16cedc9b93701b1446b86092f08b24491b/mypy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:82d0f94c8587ccb472622ee7795280aaa38a06640d5f45b3f16909d6dd86a989", size = 10944729, upload-time = "2026-10-01T20:40:15.203Z" }, + { url = "https://files.pythonhosted.org/packages/44/f2/eb15183c97c69d7cbfac990a6efd33a19ecfd97dab9e714c742fa78a784f/mypy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7da85fbcff6dac1abcc636707bed38b45598131fb7a605d9719c70b5cc733af8", size = 14213395, upload-time = "2026-10-01T20:40:21.178Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b5/ba91b6ff65e4d6b6ff53b2b3b3ac5f1babf0c7c27d0b43a0196b1c967926/mypy-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4209da39d85cf240f762af622d8180fcdfcb4727d021f44ade62d613a1a43324", size = 14373009, upload-time = "2026-10-01T20:39:58.86Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c4/484275efc935c0003e55e4e8a33e4b8e99528ee956c12256ece4708f903f/mypy-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be721bd4bd57576193653b75b4af3461c9d0bf7dd8b528f782e9be210dc75bb", size = 15465557, upload-time = "2026-10-01T20:39:01.794Z" }, + { url = "https://files.pythonhosted.org/packages/50/30/66eb6fdd0875e3c9025a02f0bb0ea2e524b74274b658c37fde0068c4939d/mypy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1fde197ae65be856a034a91b70ed747a16562ca69577785f06c661548424bf1", size = 15728673, upload-time = "2026-10-01T20:38:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/58/bc/2aa98fd7f49c42dba8e9c065886fadffdd00f3c654cff3f2a7103a797ac8/mypy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:295ecf2e57542cd836ca537486951289678c8c7d1ee6ad74ebe29b2168a003cf", size = 11389803, upload-time = "2026-10-01T20:39:37.547Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/17b60df2d946792ef6af43b89351f5c9ddabc69053206b2304945572a744/mypy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc378bdad4e9f12b5bd96466083d1e71acf00594ec9c7b2bdb5e02816f77f303", size = 10951060, upload-time = "2026-10-01T20:39:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/e0/66/924be0b653372ed31ad5c48e26044cc00840e591943a56e61621cf05b60e/mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437", size = 14234230, upload-time = "2026-10-01T20:39:44.535Z" }, + { url = "https://files.pythonhosted.org/packages/56/39/c4f176880a4177123576de6cec6309feea8f42fca2bf2f6584e88054f656/mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e", size = 14392944, upload-time = "2026-10-01T20:40:05.78Z" }, + { url = "https://files.pythonhosted.org/packages/bc/1c/26e16977e25ef2494a74f8ffc872a76ec2c3aa43c3156057cbdf88f352c5/mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b", size = 15441463, upload-time = "2026-10-01T20:40:32.412Z" }, + { url = "https://files.pythonhosted.org/packages/31/9c/9e4b049f0ecefbfd6817ca2a16eea55dd74a07950268edc6cffd29ccfe98/mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566", size = 15705406, upload-time = "2026-10-01T20:40:34.844Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5e/e861b5f6c5ef9ee6cd24683aed1edecf82a26dbd536049f9b7850b586267/mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe", size = 7897523, upload-time = "2026-10-01T20:39:39.861Z" }, + { url = "https://files.pythonhosted.org/packages/3a/87/61ffee58b25a956532a6006fae84918a2de849ed25f397492b2e815fe7b3/mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89", size = 11576345, upload-time = "2026-10-01T20:39:41.553Z" }, + { url = "https://files.pythonhosted.org/packages/a1/88/a331c20698971c2ce8d1c30f317fd61b5be13a85b1f053e12dfa22ac6568/mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799", size = 11172062, upload-time = "2026-10-01T20:38:59.534Z" }, + { url = "https://files.pythonhosted.org/packages/47/c0/4f7daa73270dced8e86c6f4a911c68082d03a84e6c1ec9bd916028d66131/mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97", size = 15711208, upload-time = "2026-10-01T20:39:30.597Z" }, + { url = "https://files.pythonhosted.org/packages/2f/05/f1afa303c678be24cf7a266d38fb24b3de4599a024c2f9ba0d5905a3efa3/mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359", size = 15935442, upload-time = "2026-10-01T20:39:22.781Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d6/6a1a45459b63716e0d035f4892a926d3054f0a8cbfa02551d228a9946083/mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3", size = 17240044, upload-time = "2026-10-01T20:39:32.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/85/ae33bee66c13f98d421964d87bf0888be941063bc75c1204a4cf142cf1cc/mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c", size = 17545856, upload-time = "2026-10-01T20:39:08.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/de/eeff209b65c3e267818d79333bb9f058da6e4e73761461bd94748a2eb628/mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76", size = 12369342, upload-time = "2026-10-01T20:40:10.516Z" }, + { url = "https://files.pythonhosted.org/packages/2b/43/e62d8d5c1dd737aa248968302ff7d6eabf997c3301773ed8bcb64932ca77/mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37", size = 11849099, upload-time = "2026-10-01T20:39:51.834Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5f/335b8980055118dc131355155883fb676bb2161a0d97e08658e479c776fb/mypy-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:afa89837d9be67e0cadfa33bca3bb7efdda98c3b07e74dc3b635ebfb1c8a926a", size = 14241302, upload-time = "2026-10-01T20:39:13.227Z" }, + { url = "https://files.pythonhosted.org/packages/18/37/1482fdc49332b145828912b15f16eee0c4ca70a8bce0f6514ece79b14680/mypy-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb443e81057896132d3642d6be219e6efd158691ac7883e3ba8fcb469865f05d", size = 14408200, upload-time = "2026-10-01T20:38:52.576Z" }, + { url = "https://files.pythonhosted.org/packages/04/09/dce2e8f6c1b31053c430ef6963f6f7a38ccb49b90c5e01e37ed0129b7d5a/mypy-2.4.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f38f57d344f8b6accb40e01c3d83cfc590498231724d16c07ffb7940f157818", size = 15444409, upload-time = "2026-10-01T20:39:18.1Z" }, + { url = "https://files.pythonhosted.org/packages/43/c5/91b68306da4cd280cb15be35dbb5ffc343cabd67c4b121b6625bf2d13177/mypy-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e76172710bd4e5eeae061abfd68347e5264632e02778be61784671ae3a2132f5", size = 15731186, upload-time = "2026-10-01T20:40:23.426Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/2d0340182a8f27352fb1e25531355951108b506097c433937e9eec452ffd/mypy-2.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f83353e47ab520bf6fd4df8f5897d9fe081211f2fbc4b7d37736a3e3c166cbcf", size = 7897838, upload-time = "2026-10-01T20:38:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/3e/08/32703c117e134c02efa2a82705bb40cee91eaedea10b6530e20eba651b1f/mypy-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:970b221ed5842213d98e3c480c08f795ace4b1f81fb21e1b126bd0476bce1c34", size = 11575700, upload-time = "2026-10-01T20:39:53.982Z" }, + { url = "https://files.pythonhosted.org/packages/ab/08/08bb269feafdaad031046ee2d771528a64467e6a180f962afc28c4a3ccd8/mypy-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:502b94b0b331f7dafe32fd6b151797ddbb4f32385b362e722c783a025e5954a3", size = 11172908, upload-time = "2026-10-01T20:40:12.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/3f/c5c92626006ca92c7686adfbece47fc6a0daa0e54753952a9ad13ce0561c/mypy-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c9de622fd397495695d0598ddc789222bfcfec9d7c9ec3a1e385c855e3bc5e01", size = 15705970, upload-time = "2026-10-01T20:40:17.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/f3/863365f7997a76a5a1dd42d7902ab05afa4124e8419089cbca1ce2554db1/mypy-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f459f0b4f0596d9d51fe7716b404b35287b99e77da98a7af90a65dd5fd61141", size = 15942234, upload-time = "2026-10-01T20:40:37.243Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/2f45b1f425a2c95dbe1a3f4d076bfd42b76e9615dba5906230703b14e4eb/mypy-2.4.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9b028548b3af480e2b1ed8df14ccaac86f99c9f600d1580770ab7ba3dcd40f0", size = 17218364, upload-time = "2026-10-01T20:39:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/5b2bbfc69e84800b78fa2dba16d995b003f93b573558e412c5490d6e6c37/mypy-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:cb734b2668c1f40d07ce093bbeb4407e9527c67901627b0e1679825be3f09975", size = 17557934, upload-time = "2026-10-01T20:38:46.077Z" }, + { url = "https://files.pythonhosted.org/packages/df/c0/1a5dc601c22041a7bbfe1ff71cf097fbbd4fb49930867c6789baf5102106/mypy-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:172e30b8fea631fe310f0c665477f52d9ea40bb4e99e0c81dc30118563b13710", size = 12350955, upload-time = "2026-10-01T20:40:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/697e9e26fc2a86c094ad67ee1e419971b7f01ded66ee66231b09e9f3e12e/mypy-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:5786ef987b3767e51aaa53f20aec104c0252b42ecda7aef8e8b4cbae279b05c5", size = 11842738, upload-time = "2026-10-01T20:40:30.026Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734, upload-time = "2026-10-01T20:39:26.837Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "opentelemetry-exporter-http-transport" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/0c/e3ebdb4b507f66afcc905e6885a4946969bd75b45988492643356fbbdc63/opentelemetry_exporter_http_transport-0.66b1.tar.gz", hash = "sha256:443080203bf52586ce0b2ad901e8951c61833eab1aa539ae6f1f16fe9e8e7952", size = 11693, upload-time = "2026-10-06T17:32:59.65Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/69/6af86ff66492b481c6a4c05dcfd68beb47ed8ba046440a26a2aac76b95c7/opentelemetry_exporter_http_transport-0.66b1-py3-none-any.whl", hash = "sha256:2f95404bdee7f9d2d529c7de56c7bd86d014d774d8fbf137810e0167f8a492bf", size = 12155, upload-time = "2026-10-06T17:32:35.454Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-common" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/19/41de712173f43057e4532d42ece7d0c6d4210d353e5752433cb14987643f/opentelemetry_exporter_otlp_common-0.66b1.tar.gz", hash = "sha256:6b1403487a2185ac1feb45fd5546fdf8630ce71c36bcefaadf51e2130e9e23f9", size = 14325, upload-time = "2026-10-06T17:33:01.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/39/8c23d67665c762aa51840fa06f86e902e8f6f1693bc8d7e3d98cd6e2f753/opentelemetry_exporter_otlp_common-0.66b1-py3-none-any.whl", hash = "sha256:00ff8592c3a7cb729ff3fdc7ffa12372c243bdf2163e80c180994d0c7bd83ee9", size = 12385, upload-time = "2026-10-06T17:32:38.177Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/8e/65e85e5137991a3c493b11682151d198638a5bc1dd4b4c5f67e013c57d7c/opentelemetry_exporter_otlp_proto_common-1.45.1.tar.gz", hash = "sha256:2e4adcc3a67bcf57804fc49514f0ef64974ca7590aa3491da389852b4a0628f6", size = 18873, upload-time = "2026-10-06T17:33:04.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/aa/92f225d353904e7f70b8b3e3c1b02db0cf56f744c2e83c581dc372e78873/opentelemetry_exporter_otlp_proto_common-1.45.1-py3-none-any.whl", hash = "sha256:2f446183ae7047b036226f1d846c41a834b0e8755ad13b51a51dd38952eb466c", size = 15393, upload-time = "2026-10-06T17:32:41.911Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-http-transport", extra = ["requests"] }, + { name = "opentelemetry-exporter-otlp-common" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/17/26487707ea4caa97b17e6e4b5fa72133a53512ffa2f5cf7a49ef284b29cb/opentelemetry_exporter_otlp_proto_http-1.45.1.tar.gz", hash = "sha256:45c218405ce3fd879596924b1874bf9a8f6880206d61065c5a912c8e5c297fb7", size = 28839, upload-time = "2026-10-06T17:33:05.713Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/1f/517eaa0187ba106a9da97160ce2add3a371812681dc440930b267f714e42/opentelemetry_exporter_otlp_proto_http-1.45.1-py3-none-any.whl", hash = "sha256:24a97cf3753c7fb52fad44a696e452ff371686339e2acf3309e2eda3d0230700", size = 22180, upload-time = "2026-10-06T17:32:43.946Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/03/89e47ff8d52a4f83b343e6eb9ef1698ff45357216e5b6b2b21e0da5c5c7d/opentelemetry_instrumentation-0.66b1.tar.gz", hash = "sha256:e79a510f7d87c72d95e964ddb42193a0d9a75668c027d980eab032ea1322a5ce", size = 43308, upload-time = "2026-10-06T17:36:10.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/b2/d1413681ff43e13ac9860df27e1226d3199ab0b97b352ceea41abcc660a5/opentelemetry_instrumentation-0.66b1-py3-none-any.whl", hash = "sha256:4c4aa14dc9a24a02325a9d4c42c4d0208dbb1374c2b1b8fe6c9392d59f3e1008", size = 36904, upload-time = "2026-10-06T17:35:11.663Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-asyncpg" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/af/283db299795441d914045e051c339767e781eae6dde9846659e949731806/opentelemetry_instrumentation_asyncpg-0.66b1.tar.gz", hash = "sha256:ac244bd5cb23363880a7937763d0810035815dc74a8389d2aa1f73d532ae47aa", size = 11413, upload-time = "2026-10-06T17:36:16.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/59/f4c71b0023cef5cfb0c77fcd3d862cfe52892c4142797256ba1ffb036676/opentelemetry_instrumentation_asyncpg-0.66b1-py3-none-any.whl", hash = "sha256:7fe611aa899ac59f4a34b05e817a61f4011dc50d1545c4f48b77a4c62e663f05", size = 9750, upload-time = "2026-10-06T17:35:21.113Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-redis" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/f7/8a7f4d2178a79b4cca971836491d75884b8f2b3fcd0dc9a3d521ca65e7bd/opentelemetry_instrumentation_redis-0.66b1.tar.gz", hash = "sha256:d6cc6aa473e23692ae1be9011e2cb3ae5894376158533a71f9710e1fb0906dc7", size = 18060, upload-time = "2026-10-06T17:36:36.289Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/22/19286da994ae57c78ecc3f4bd692855ccedc54cd9b05ce56592ee28964c3/opentelemetry_instrumentation_redis-0.66b1-py3-none-any.whl", hash = "sha256:4e36651d553e63c8e8f4068d8a2362ae35ac5c9bbef53c27a0fcb96d9092dbb1", size = 14885, upload-time = "2026-10-06T17:35:50.394Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-system-metrics" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "psutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/81/ab8cabaf45c5248fa88a0d0d3acea4483c105d76cd6c849654ebdab16f92/opentelemetry_instrumentation_system_metrics-0.66b1.tar.gz", hash = "sha256:b0fccbc1aecf35c182431a035ec4809b8f9607eec692243cf70f23b3bda51231", size = 17207, upload-time = "2026-10-06T17:36:40.518Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/fd/ea072c512d65ba4054b02c8b7080cf1b362761d4727ee6c1f36dce53328d/opentelemetry_instrumentation_system_metrics-0.66b1-py3-none-any.whl", hash = "sha256:0654b41eb6db3c2f9ad9979487afcc6f461e5b35df282f0a11d0f465e906d967", size = 13913, upload-time = "2026-10-06T17:35:57.01Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/7f/15f014fb195da6c2dbb6c71399b8e76824878718e94de6454038488eed28/opentelemetry_proto-1.45.1.tar.gz", hash = "sha256:79e0fb95e4616691a469439238aa9224d75779b3e108e895d1aa125ab29ca77c", size = 46488, upload-time = "2026-10-06T17:33:11.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/9a/42ec8180a769516ae757e893b69736826efceac7332553915b4528a91c6d/opentelemetry_proto-1.45.1-py3-none-any.whl", hash = "sha256:f38e2a8413053c180cd3d2637fbb279673ec2f6a6e09c995aafa2f452c52b46e", size = 72488, upload-time = "2026-10-06T17:32:53.057Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/79/7392e21a1c8f0c61d90b223e31c7e48cb9d452e91a6b820ad24cca5f23c4/opentelemetry_sdk-1.45.1.tar.gz", hash = "sha256:63d24a6ca645019a631e6a51999c73e93adcac1196ca640b8ae78a7cc4762bf3", size = 218324, upload-time = "2026-10-06T17:33:13.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/3c/87c42b4bd6dd297536f04cd9383d212ac557ecd49f2cbdcd46da1c9ef5c8/opentelemetry_sdk-1.45.1-py3-none-any.whl", hash = "sha256:c604c11dc429810812348989115fa44bd558772a3d7442afc43d024f2c250ca4", size = 140063, upload-time = "2026-10-06T17:32:55.04Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/e4/dbbfb2a010c4db2224a5114638acede6fe563d33cc20fb1752cebcbe6298/opentelemetry_semantic_conventions-0.66b1.tar.gz", hash = "sha256:497ca63bf383723411e8eaf60c8779e9877633c936bb641080adab59d0eb6ec8", size = 150250, upload-time = "2026-10-06T17:33:14.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/14/67f8aa798857f8cf686f515bf93d9bb877ce952ddc8efae0fa25b45ce0d6/opentelemetry_semantic_conventions-0.66b1-py3-none-any.whl", hash = "sha256:d4cddeb4315490b35213f55e2bdc9ac54bb1e4d318927475bed62b35545e581b", size = 206279, upload-time = "2026-10-06T17:32:56.103Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "paramiko" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bcrypt" }, + { name = "cryptography" }, + { name = "invoke" }, + { name = "pynacl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/cb/b1fde76618d84b1acf54d3d03d2b833ba5878afd12b9899346cc080f209d/paramiko-5.1.0.tar.gz", hash = "sha256:239b5f9b2a53006cd54f83cf8495ee742083708ead947882c8dd2786de79f39b", size = 1579549, upload-time = "2026-10-18T02:38:51.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/45/fa0e8d70cb25d2dae06bc238e580a84834f6ca53a4e2408cccc19a78db7b/paramiko-5.1.0-py3-none-any.whl", hash = "sha256:d9fe6d67a9d0bbf3eb85b051d26ab850b678c02d060f32915b944e5ca6d0d9e1", size = 211623, upload-time = "2026-10-18T02:38:49.903Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "protobuf" +version = "7.36.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/89/5b8517baa72f84a67b8a307ba953c91057af618bf40bf676f3c03551f8f0/protobuf-7.36.2.tar.gz", hash = "sha256:497d0463ff3316681da6c0b9e8d06cb465d61abce00b613ab42226175644d1bb", size = 512737, upload-time = "2026-09-17T20:07:59.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/72/98342feb672507c8f3a69e34b4fa8961f608edba5c1a48a6f47156d92cb5/protobuf-7.36.2-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:cbc70b17ee27e28894c7fee8bb04be1abead49e936bc70eb60052531eee2079e", size = 456039, upload-time = "2026-09-17T20:07:51.542Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ea/91fdf7c2b8bbd49cde056f00a9df6773532987e1c00fe2830b895af95c7e/protobuf-7.36.2-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e", size = 344219, upload-time = "2026-09-17T20:07:52.914Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/5fd5f8ece73fad885c5a09aa849b32d70472f954ba3a92d3bb5974ea953b/protobuf-7.36.2-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:f4fee11ec330d238b34a05c9b675f693c20415d1c5bd7d5320cc2f8a798eb9cf", size = 357223, upload-time = "2026-09-17T20:07:53.985Z" }, + { url = "https://files.pythonhosted.org/packages/db/f3/3996583dd2906297a637af12114deddf7658af6e683fedb83be061983fb5/protobuf-7.36.2-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2", size = 343223, upload-time = "2026-09-17T20:07:54.931Z" }, + { url = "https://files.pythonhosted.org/packages/fc/1b/dcc64f358fcb51811b58ae40b3d28f820725f116d86487cc20bd4b130701/protobuf-7.36.2-cp310-abi3-win32.whl", hash = "sha256:912c1221170e16c08d1f086762f563dd61ff83c18b5fa6652952dfaded66f728", size = 442998, upload-time = "2026-09-17T20:07:55.826Z" }, + { url = "https://files.pythonhosted.org/packages/8a/55/b77bda4e5e5f5971fb51b07663694690e9afdb9402136c16a522bd621cad/protobuf-7.36.2-cp310-abi3-win_amd64.whl", hash = "sha256:a300819d441e078a5608c0d3c709796bb548136058fda017ae51d425b44fd353", size = 456514, upload-time = "2026-09-17T20:07:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/d52c7016b04b6c5108f26691f9d33ec82a9b65d041f1a9c771137693d618/protobuf-7.36.2-py3-none-any.whl", hash = "sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e", size = 179806, upload-time = "2026-09-17T20:07:58.211Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "pwo" +version = "0.1.2" +source = { registry = "https://gitea.woggioni.net/api/packages/woggioni/pypi/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/pwo/0.1.2/pwo-0.1.2.tar.gz", hash = "sha256:dcbd0416a2d3f4cbb25a6f9d0921c132f6c0a44deb5ed800a2f0aa19dffb4b6b" } +wheels = [ + { url = "https://gitea.woggioni.net/api/packages/woggioni/pypi/files/pwo/0.1.2/pwo-0.1.2-py3-none-any.whl", hash = "sha256:37a32c2126a9251471cadc76fb423b8801f4f0fd1ce0c91c97d4f612ae4ed30e" }, +] + +[[package]] +name = "pycparser" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" }, +] + +[[package]] +name = "pycparser" +version = "3.12" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.11' and python_full_version < '3.15'", +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz", hash = "sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6", size = 158785, upload-time = "2026-10-22T13:40:03.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600, upload-time = "2026-10-22T13:40:02.021Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + +[[package]] +name = "pyopenssl" +version = "26.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3f/e8/7325d258199b159eb2c03fe32107533e2832e70e63f4fb88a6aa00023201/pyopenssl-26.4.0.tar.gz", hash = "sha256:28dfcce0162b9211413e26dfbfdf1d24317fbeba18fc93c12400a1856b2a0bc7", size = 182046, upload-time = "2026-08-01T19:50:50.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/ad/2cf6d3fa2fae5c79e1ed9960c0d42badd0f94d81dd12b50604cdc839e648/pyopenssl-26.4.0-py3-none-any.whl", hash = "sha256:f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", size = 56026, upload-time = "2026-08-01T19:50:48.94Z" }, +] + +[[package]] +name = "pypika-tortoise" +version = "0.6.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/06/89fe5fff93c5a01dbdeb9f3d843a7e997dc6e3a87222a260a164ff91fb81/pypika_tortoise-0.6.5.tar.gz", hash = "sha256:64d96c9b88450f6360ad22a7063933b6a90961a7317f04b2b63c98fd5d705506", size = 81468, upload-time = "2026-03-13T20:44:54.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/b8/502910eb8b315f719d8f6a6509f13a38b6c4c05378f14ac151ff347bff0a/pypika_tortoise-0.6.5-py3-none-any.whl", hash = "sha256:9194ac6ce6ac9bdfc6e959c831c5788ef05ee1371e82ba281b0eb75f4a2bd4f1", size = 47936, upload-time = "2026-03-13T20:44:53.541Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "redis" +version = "8.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11.3'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25", size = 5254356, upload-time = "2026-07-30T08:51:00.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb", size = 560618, upload-time = "2026-07-30T08:50:58.497Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "tomli" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/ab99b60ee52acd949684febabc3005d0045d0f66bebd9cdebd67372d26dd/tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545", size = 163901, upload-time = "2026-10-07T12:22:15.601Z" }, + { url = "https://files.pythonhosted.org/packages/bc/00/ee01b7ed4579180fff07142d290257f25ba786f23f3ec6005f620933c2f5/tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef", size = 163756, upload-time = "2026-10-07T12:22:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/72/c2/4efebf65372f6583185f79799312109dddb61102d47e5c33dcfd1a297aca/tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b", size = 268038, upload-time = "2026-10-07T12:22:18.135Z" }, + { url = "https://files.pythonhosted.org/packages/53/07/5850468e925d898abb36038666f9c333a94d2a223e802a8ba5b6d319d23f/tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56", size = 276422, upload-time = "2026-10-07T12:22:19.567Z" }, + { url = "https://files.pythonhosted.org/packages/b4/87/f293984cdcf83c054196d4fd3dad44fc68ae55b4b8c44bc76cef360c3150/tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1", size = 272616, upload-time = "2026-10-07T12:22:20.794Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ce/db582886b3c1219d3fec93ebd669332482e5aee7a91e0f7838d84f2d1759/tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885", size = 276593, upload-time = "2026-10-07T12:22:22.12Z" }, + { url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830, upload-time = "2026-10-07T12:22:23.651Z" }, + { url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742, upload-time = "2026-10-07T12:22:24.972Z" }, + { url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332, upload-time = "2026-10-07T12:22:26.117Z" }, + { url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" }, + { url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" }, + { url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" }, + { url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" }, + { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" }, + { url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" }, + { url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" }, + { url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" }, + { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" }, + { url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" }, + { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" }, + { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" }, + { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" }, + { url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" }, + { url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" }, + { url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" }, + { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" }, + { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" }, + { url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" }, + { url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" }, + { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" }, + { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" }, + { url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" }, + { url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" }, + { url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" }, + { url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" }, + { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" }, + { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/72/fc01563df95c189a9f65850096bdc205bc2fe37da6c740cd64afaf18d07e/tomlkit-0.15.2.tar.gz", hash = "sha256:83e32755ba1d20d9340bd069ee97e9f6232ba2346b63ffcba425de01f7e327a6", size = 190522, upload-time = "2026-10-16T04:07:08.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/46/7d84138a60a8bd796825b2d38ebe92d0d16501ca57d501b7c5d677401bbc/tomlkit-0.15.2-py3-none-any.whl", hash = "sha256:05562412163a3037b94497385fb7f9204c637bc605af7a48199e41cf2272f437", size = 52225, upload-time = "2026-10-16T04:07:09.216Z" }, +] + +[[package]] +name = "tortoise-orm" +version = "1.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiosqlite" }, + { name = "anyio" }, + { name = "iso8601", marker = "python_full_version < '4'" }, + { name = "pypika-tortoise" }, + { name = "tomlkit", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b8/19/b26db2d0cc5599715e15409ee63af51b4d637faae21a98fe26d58354ac2b/tortoise_orm-1.1.9.tar.gz", hash = "sha256:0399e1aa9518620f8b9c455598ac06ab890332c03e335cfca4b73b48d4e6abaa", size = 410828, upload-time = "2026-10-14T05:49:04.459Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/69/45787b25cadada61593ac4ea483f793b66497f7b29b51c3158ac7fe4c5a6/tortoise_orm-1.1.9-py3-none-any.whl", hash = "sha256:9304796aef7406cfd2b9d8e2c42598e56df015fb61a2718bb0df08901d2253ce", size = 282165, upload-time = "2026-10-14T05:49:03.03Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260906" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/6e/abec85b9013db5b934b0280a6dd104904d84f7bcbaab2e2f3def87ac7463/types_pyyaml-6.0.12.20260906.tar.gz", hash = "sha256:f59c1cc05010b833d2d72287bbaa72610106b28d42d89a907313117faba85212", size = 18649, upload-time = "2026-09-06T06:35:35.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/c0/fc0644b7ddcfb969e95845837143cb5173ddd6e06ee4ba5fc493cd9329b7/types_pyyaml-6.0.12.20260906-py3-none-any.whl", hash = "sha256:bca893ff0d51df5c9053137d5d0e6ccd36e939a196356f1d5c16372422f5137b", size = 21282, upload-time = "2026-09-06T06:35:34.372Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[package]] +name = "wrapt" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/22/89e2f3bdae5cb34e0cab0cd86d7172dbf418de4b46c9b17b9c7a560dfa44/wrapt-2.5.1.tar.gz", hash = "sha256:f595bb0185aab3e9dc31950c95d914f56ea8278810c3b928f3426e12ed6d27bc", size = 184455, upload-time = "2026-10-14T00:39:39.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/e9/a5fd28d560766fdf9d78834cca7122dbe421567d1bb3083918ac03e8fb3f/wrapt-2.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c40f3b1cd3ff9dd9f4ae829e4301f0d3a553e3467058b8c3f5528fee2c768a20", size = 105226, upload-time = "2026-10-14T00:36:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/fe/13/38001295886db51429dd29882c9097fa56ff642f811439ca13998127d8d4/wrapt-2.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9bc472825027b276d4bf678d2ac64149db0b122f80ae6f59c423e6d31f0c4bb7", size = 105693, upload-time = "2026-10-14T00:36:46.154Z" }, + { url = "https://files.pythonhosted.org/packages/84/d0/9c0409db3062fc7ef8347c578a251b0fe3471741871c3bc849ea07c98e98/wrapt-2.5.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:016602dd8827d190280a707c5e67f9a80038f54bac1782cc8ff68a2a16c618bc", size = 231346, upload-time = "2026-10-14T00:36:47.611Z" }, + { url = "https://files.pythonhosted.org/packages/a4/da/a295034b0ba4b4a66ac8c173312214845c406f681d7fbf636b57c6ff8f7f/wrapt-2.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bdf4696fb5bb141a7f96710ac6d9a6aa9a57a14c54075f9c7d3946869d457df", size = 232994, upload-time = "2026-10-14T00:36:49.346Z" }, + { url = "https://files.pythonhosted.org/packages/0b/19/e6927c9ce75cc9f96672d367a722b0addc81e5042580966705ab5424b92f/wrapt-2.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ad562c23e61e626f9d27aa37aa5679f1c29085de1f998466d107854048bba9e", size = 221366, upload-time = "2026-10-14T00:36:50.887Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ae/bb859f768000937459bb3b85dc5daeab03160873d8e0ee0644d4976f49d0/wrapt-2.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:da42395e7add724c1f7caf18a2977b1fbdfd5aab314e5622731f0ed66731eaaf", size = 230889, upload-time = "2026-10-14T00:36:52.547Z" }, + { url = "https://files.pythonhosted.org/packages/67/bf/244c23a7c342445325086aefa86b0f9b79276d1d4770d4f160c62721d756/wrapt-2.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ea27bcf5c56b13463ba5b9bbfa4d6544997e47ba6db77c59a259b09daa802d4d", size = 218751, upload-time = "2026-10-14T00:36:54.179Z" }, + { url = "https://files.pythonhosted.org/packages/a3/25/a94753d22152cd179152c58304d49c317143a9ca1333d42f3304528eb7ae/wrapt-2.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7fa321270b40f3e8cdfd954b3a8dcafc6db1d8bbd4d681b92dfa6b9ef91a9a99", size = 220764, upload-time = "2026-10-14T00:36:55.696Z" }, + { url = "https://files.pythonhosted.org/packages/e6/be/7a7d60378125d097b80fcc2f1050d84e2df1de228ca9d95dfde9cb6c3b62/wrapt-2.5.1-cp310-cp310-win32.whl", hash = "sha256:c4d9c76e9a16a8bae0bdcc57efabad499192565bd9a95258b01fb0b49a62bd63", size = 100594, upload-time = "2026-10-14T00:36:57.111Z" }, + { url = "https://files.pythonhosted.org/packages/cf/17/dd981d8a622d171f81e95d557687fd572a7815952a41f649e8c8ec8eb27f/wrapt-2.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc0eb73b450b53950b7879ac7642889c82918d17bd2d877fd7270348dfd5550c", size = 105660, upload-time = "2026-10-14T00:36:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/5d/06/34f87709fa8fbe895c1f5d43ed98a669956c513df649ea1e39a75923c396/wrapt-2.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:22300c5f254627f24ad2197998fde26db6eacbb0f879162944bf7bd79dd5ee5b", size = 102887, upload-time = "2026-10-14T00:37:00.189Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2b/0f2ae9e355a0491c202a1331ec405d794c249f4fe9b4db952c0246909786/wrapt-2.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aed178902c2386d7c5d3d23eb96d32c100e34cb8c2390e7ece0e4901ae43f0e7", size = 105587, upload-time = "2026-10-14T00:37:01.614Z" }, + { url = "https://files.pythonhosted.org/packages/0a/54/a5b9904d341ae255bc5618ac43830ab68ed6420dac62af9a305a6191062a/wrapt-2.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1910be5adc0232cc6e8c0673bf3f41c2ee724547543526bed8d00734458e7bc5", size = 105759, upload-time = "2026-10-14T00:37:03.02Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bf/8edaec939d7411a58bccb4dc4310246caea5050576ff91b5f5abf6079a9c/wrapt-2.5.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c25c594f58ecb676358d6d6b0ff068b8bbbc506dc831c6d17876460c66ce39c2", size = 240110, upload-time = "2026-10-14T00:37:04.448Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8c/18ad7f24c82cbf689324521abe4dc90078d20a5498d991ec83e03dc60c85/wrapt-2.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85a9db9e5a5ccc326edb19e35a5106ba16e451d570a2ec8ea9deb1ea52a3c42", size = 241618, upload-time = "2026-10-14T00:37:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1b/607e1fc9a8e8838f1a8516f5b87565c9dc415b474ec09479f9443a5a7305/wrapt-2.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2c642a83b6703804b571caa3b8b205aacd341b1b37e2b2d89cd70e03e0e9caa6", size = 226604, upload-time = "2026-10-14T00:37:07.899Z" }, + { url = "https://files.pythonhosted.org/packages/b4/fb/6f637ca3e71ea046148dd17622e023df159a16d99762aad4bbdbac11c76e/wrapt-2.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:920f700ef41ee774a1e4778c1f4295e117f1ff3435a7e0cd3e997d10da819d32", size = 239265, upload-time = "2026-10-14T00:37:09.45Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fc/b746f3a72ee7f56d238debeb3842b2569511cf357f5a35f455e24258dc4f/wrapt-2.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3f93ceb0ac4896de45d5a45a8f4e69474da583440589de10b362ddc1db4691ed", size = 223864, upload-time = "2026-10-14T00:37:11.363Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/290a6385393fddcb01bcc83d78c16a24c50a392fe72fb4936999bfe573ec/wrapt-2.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a88370a7d89fcb1c4953a87673fdd7b4a0eb14a1a4dfce49771f0c827ef44893", size = 229426, upload-time = "2026-10-14T00:37:12.868Z" }, + { url = "https://files.pythonhosted.org/packages/29/af/b52bb81d4217ed7f0fe285de6754e44cb5e6a8b316fc78c605743f463709/wrapt-2.5.1-cp311-cp311-win32.whl", hash = "sha256:12bee472452019706fa1d4ead093f52a9683b4fe6617953e15bab9acdfdc013f", size = 100684, upload-time = "2026-10-14T00:37:14.59Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d4/ae9ca837038a7a9aadb906eeca76b42df54e299edb29f8ac2cbad1d55b4f/wrapt-2.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:ce3889e3815f97d46414eb574bffdd9bdb41ff70f503097e2707615a87d4e92c", size = 105878, upload-time = "2026-10-14T00:37:16.057Z" }, + { url = "https://files.pythonhosted.org/packages/15/5e/0605567a81c7105446cec682bc2235172a37e2bc817e2de3f5bad95ee969/wrapt-2.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ca7b967e96384abdf7e7182c79f71529997981ece8169f8a8ddb31bc5b57cbec", size = 102825, upload-time = "2026-10-14T00:37:17.492Z" }, + { url = "https://files.pythonhosted.org/packages/66/9b/c7f97d5493a33b5ed01d3c85745f9bdfdd2e5c2785471b8d8b55a3c273d6/wrapt-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6e3eff05ae616671b40d7ad0a504210329e4adc9fb91415663570aca93c5f5cc", size = 106452, upload-time = "2026-10-14T00:37:18.951Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b0/335b0af2930938678fcde954b29780b26308961b93df5e0192fc182e8b7e/wrapt-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c44dd9881626da7d621c23805f26726f6b023cf3e9755f48d092bc9cbef4a8e7", size = 106087, upload-time = "2026-10-14T00:37:20.392Z" }, + { url = "https://files.pythonhosted.org/packages/4a/5a/2a34ba5a468e9d3d6e5b0733280e1ae3c850bfc5f1d681fc0e97f564d1f2/wrapt-2.5.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bfaa998ceeea4d0aa72b40cdd0023d19409504e244b439ff2aa9f01729341c5f", size = 250438, upload-time = "2026-10-14T00:37:21.882Z" }, + { url = "https://files.pythonhosted.org/packages/b3/d5/3d4ad322af74d3ab2a14f69ba844cdd3edefb555edfc1c1976ec0112d5c4/wrapt-2.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6d274ec50a5b208be75596dc44ea253e65deaa6ee3a600babc86dafbb957dfc", size = 250594, upload-time = "2026-10-14T00:37:23.497Z" }, + { url = "https://files.pythonhosted.org/packages/37/1a/3cbf48425ec2c66aa9645218458da1e19e315abb9766604d3c49e579076c/wrapt-2.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1a96e2671c60f9f09ae547b5a815cecb29af16caa68d73693387d0028788cb32", size = 229856, upload-time = "2026-10-14T00:37:25.029Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e7/b2ea57f4c51258659200565af8617d76992b0fe65e6aad7162dd5720ef05/wrapt-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:729d644b6acaf4846a4ef81b037857b66a01dea6d227f827c6d71c0b6d656d6c", size = 247153, upload-time = "2026-10-14T00:37:26.67Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c1/4714743e672ed1084a035a2a4f0edeef7838399753b4856a0dc46ef9487d/wrapt-2.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:859f67bfc31eb7ab55f237b629cd4ab0441b075912446481f910f7d02066811e", size = 226726, upload-time = "2026-10-14T00:37:28.425Z" }, + { url = "https://files.pythonhosted.org/packages/91/e3/c00401bcc3485eb9937c3fe4a1cc8fc3b61800b1378ea3a143ea1c30f6f6/wrapt-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:29b62e87fcd6a1893f669abfd02a596a7fc5cfa79fa57e42c4e650a6c170c67b", size = 238843, upload-time = "2026-10-14T00:37:30.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/b5/c16759fb0721e63df92b576c2222ce1f11690a8b300fd91b49c55436865c/wrapt-2.5.1-cp312-cp312-win32.whl", hash = "sha256:f1c911818fb076910ef509f2298dfcb966a54a6ff068eebd459632102cf589fb", size = 100488, upload-time = "2026-10-14T00:37:31.625Z" }, + { url = "https://files.pythonhosted.org/packages/22/d5/39d5a704650f18799f37841442b464edb81cf2015f006eaef26068acc6ea/wrapt-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:c39c7130ea0702c4ab0faf12da1df1e02d5174305c17edf02309e2f058c4114f", size = 106048, upload-time = "2026-10-14T00:37:33.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/bf/65743adeeb5476920c62dad6cded7bc8789e19bd4f9a336d4ac812adb8de/wrapt-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:e089a22ff5af1290b8c759a610830bdb2a829ef9c3d7797e4ee32c2f795ed482", size = 103200, upload-time = "2026-10-14T00:37:34.673Z" }, + { url = "https://files.pythonhosted.org/packages/e4/6d/cfe55762435f36107815d56a2cfbebe7e3129b593c47a670c6eb1d7917d3/wrapt-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f98eaf784cd12bc69c77af398084174531007cd81849c962163ccfc6e791f3ea", size = 106068, upload-time = "2026-10-14T00:37:36.087Z" }, + { url = "https://files.pythonhosted.org/packages/01/b9/41642877fe741db56d240833c8822188b663c4c5d52beb087964774035d4/wrapt-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ab6db7d2a18d366cc57c2228253cf26443190aba0a6dd0939b3c1e8ac6e29e2c", size = 106190, upload-time = "2026-10-14T00:37:37.768Z" }, + { url = "https://files.pythonhosted.org/packages/37/62/20edad100b93552ec5c172e509a9db898a73d5043ae701fcb6e9986f9d33/wrapt-2.5.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f1630201b0e2a96bb26304b7adfbd91a4ef486abb5a4c48377444a0bed749f37", size = 248519, upload-time = "2026-10-14T00:37:39.321Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e9/8d81185bc9a40cfb43d91fc70a1e80ecde752c95dc98f5452cae82037976/wrapt-2.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d800c7689154622b0ba2922ceca44a3cf2ef61c3b9a4c4eeb1d8b3050d7ededa", size = 248488, upload-time = "2026-10-14T00:37:40.96Z" }, + { url = "https://files.pythonhosted.org/packages/8a/88/8431df4fd81f0dfa83e8ede463eed311d083c5a279a56891dc0396b07b0e/wrapt-2.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b53000b424dc2133eaaf22838a2352d3497f5d7c2e7d9a2acfe675ab7225bb1", size = 227392, upload-time = "2026-10-14T00:37:42.599Z" }, + { url = "https://files.pythonhosted.org/packages/db/8a/ee6f8542eeccad6874faf0b7b2e129952c527a482f1d28940e2111fec2d6/wrapt-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76f230a9b07e3cb66646d265398f579abb6128b1bb4cb97c74b1ae5d09e96f31", size = 244972, upload-time = "2026-10-14T00:37:44.209Z" }, + { url = "https://files.pythonhosted.org/packages/4d/1f/32c59e7fd522409f3863dfecdab5315ee9ba37f96020b6f0adee9d223310/wrapt-2.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fd3f878a4aac3c262447ddf43c5f4c18fc67dfc3ba69c4fb1c7a4c4af96abe7e", size = 225316, upload-time = "2026-10-14T00:37:45.948Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d6/1b9abc1244592034c5db744571e17d663f0f1b0ce6c8ba279c60f6f9c3a8/wrapt-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0c9480bdee340a1602cae5a777146ab4be3e384fdcb569fffdf8721032314645", size = 237145, upload-time = "2026-10-14T00:37:47.535Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ce/8f3b5482f768c1d60fd2557d049c766543fef5ec707037cb410a57eb65ee/wrapt-2.5.1-cp313-cp313-win32.whl", hash = "sha256:dc401274fcc7b15b3b2c12df2ff34024a11925243a7d3daee91c6d7d14f9addf", size = 100462, upload-time = "2026-10-14T00:37:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/7b/dc/6a5735874ea79816f85c1ec9d92139d7073c20d1881c15ff2108c211354b/wrapt-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:09b1893ee4063706574c1813abf479b8b51926633fbdb6f96aab8dc7b0976668", size = 105870, upload-time = "2026-10-14T00:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/08/83/a4e8b5a5a32f8dfc5dad8344f1e2b908f7d8d84b11c3c336bf7f79a5144a/wrapt-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f280c115ea64eff3dcbd68a668ce3f63476a4ba386bbabb318017e286196ea2c", size = 102903, upload-time = "2026-10-14T00:37:52.323Z" }, + { url = "https://files.pythonhosted.org/packages/25/3d/ec1937283863bbe0d90528e09f2b27cfc0dd7e608fc2b65c804967dee369/wrapt-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cf63fffcdcd8c60f223d3967bb92cc4fc2e8b46f09e75b67a6a75e6f47c0fc43", size = 106439, upload-time = "2026-10-14T00:37:53.853Z" }, + { url = "https://files.pythonhosted.org/packages/93/39/cca8afb80dbb9fce6103e59db507a9415291c4dbe97ea055875ff62901fb/wrapt-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f0750cbc2e29e4f3c9529d3587d4e7ed8f60638ceafb80b87a95833b0c5acd9", size = 106378, upload-time = "2026-10-14T00:37:55.386Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a0/e784d7a9fd277a2ee395490ec4df96608b7fe218bb1ef7dced2a1caea490/wrapt-2.5.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3cf273b7e8d2038abb7f0a8c6550aff4f617b9d486a9965c8e8acc96a3a04de9", size = 251505, upload-time = "2026-10-14T00:37:57.022Z" }, + { url = "https://files.pythonhosted.org/packages/81/56/01ebc86b88056f5782b9d50f962fb398c6b82aa11efc98f50c64896d94e3/wrapt-2.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380f72610181883f66b41442cfc7c0f7552b42169efb2113def26e6380013d37", size = 250744, upload-time = "2026-10-14T00:37:58.652Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5c/0e8eaaf31e2d6e7bf13c6eae2fd5b85eaa24e21e06466e6e7a0f35532689/wrapt-2.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cef2a8f006410b6134a0d273ec037fea8cc7a6a914f1bd7555ad9788ad788c6e", size = 231598, upload-time = "2026-10-14T00:38:00.505Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6b/6a3e257e65de6cc0027e78b423942697c74451520ba3797fd86455ec4df8/wrapt-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9bad4dbb4e61624fcce5f301e37f9e743ecae4f1259a3777b3207eb7eba3dccd", size = 247998, upload-time = "2026-10-14T00:38:02.63Z" }, + { url = "https://files.pythonhosted.org/packages/22/38/b2b8f3ee22b05f33f5aefb052844a36a0d7edd1eaff2ff4249f97792bea8/wrapt-2.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9a34640eb6295f33ca23462977de275fe8f3a50ab339b8918b96d69a7451e2e1", size = 228795, upload-time = "2026-10-14T00:38:04.317Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/e6c86552286ac27b855042fb9c229c580ed2d4c3ced5d0a1dad5f5ee8c11/wrapt-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:26313f38d18d40a9975123a4ebff9da125ec63ab9ece4f05320a3d8d37d2c1fe", size = 239090, upload-time = "2026-10-14T00:38:06.119Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7a/aea209f64e894573935b17de26ecfa0efe139b63f170a60be9f13734e0f5/wrapt-2.5.1-cp314-cp314-win32.whl", hash = "sha256:0591e6eace0d186c9ef1ecd1244be5a04e98041424cfca425b684ffe4f0d8030", size = 100919, upload-time = "2026-10-14T00:38:07.874Z" }, + { url = "https://files.pythonhosted.org/packages/57/24/847096aa49d42990137ed3b940743c8a6da806d39f6c455317114e8ebfde/wrapt-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:25ed8b1b39234140d5b5c6a273130c7595e0abece417c3ca3cb378fcea5cd0fe", size = 106243, upload-time = "2026-10-14T00:38:09.49Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ff/1cdc742133b9fb8558cdf42b2a6c2699bd7c72f7d0606286ec2f9142e20a/wrapt-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:6201c7e122f40060a9b50696d80deec8f93b1a235ec0443f51d7a8a42f7044a6", size = 103974, upload-time = "2026-10-14T00:38:11.354Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6f/c32dc64900f1970a7f991ff5d06788cd636ca2f3ee2f99709d82577ca198/wrapt-2.5.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:da847332447db5505162759a4cd5ac374eb8b74841fe97a98ef3de14edd2586d", size = 109552, upload-time = "2026-10-14T00:38:12.965Z" }, + { url = "https://files.pythonhosted.org/packages/e9/73/a9c8cc82b166e3de42f5fbd88089d2ef9b72e87aac7d6cdddb070335c20b/wrapt-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9f437dd704abc4ee1bd03bb2d796d362d0e75915e8f3113a7900b3b7ec5f8b47", size = 109853, upload-time = "2026-10-14T00:38:14.565Z" }, + { url = "https://files.pythonhosted.org/packages/f6/48/f341d82e69ae47df2755847af1c744ff0732543482dbc2373e5e57676621/wrapt-2.5.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03aa7d2256309b57ddbf317bff2cae5f47e50ea9ae8d582780ebe0b554347b42", size = 284103, upload-time = "2026-10-14T00:38:16.23Z" }, + { url = "https://files.pythonhosted.org/packages/fd/50/b87c6374377b08ee0783b6c5c31cd41a5e103bc54e7e857e800a0a965550/wrapt-2.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fcccaa1484f7dd1091602970988ab741491f9f974013c844f70e45ac1196b80d", size = 293883, upload-time = "2026-10-14T00:38:17.986Z" }, + { url = "https://files.pythonhosted.org/packages/38/e0/6d0810ae73f7a5180ec366577588ab3ad55fc1bc7e3623e82d5f8528dd2a/wrapt-2.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8078186f719a92693199f1e06c4ec72e1e6d374c2e459da18ed5c39d6966d727", size = 263097, upload-time = "2026-10-14T00:38:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d3/c890a46f4d395a7935e5a7436f374ceefa362eb612fdd39376dd775e0283/wrapt-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1425fcf0e70b27053bd610d57bae975856e7897e3f6ba1456d2b80b9d7fd15d1", size = 289045, upload-time = "2026-10-14T00:38:21.913Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c6/042e30e0d851ca6ea743e6978902527f9166da42d736f074245d1cc54c8f/wrapt-2.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b238e955ba34ef2b8897f358b7b868b41b9a02ffd338014b62985fa91898cc4a", size = 260378, upload-time = "2026-10-14T00:38:23.662Z" }, + { url = "https://files.pythonhosted.org/packages/31/a4/5e65f90bf414c2f1c7eefc3d26c33af01d87ba33c4b879db4e1d4ba7fc3b/wrapt-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25eb4d928a9abeaf70ca786a35861b46d1ab37cc4ce49ea70a070dacdead4dfe", size = 278746, upload-time = "2026-10-14T00:38:25.523Z" }, + { url = "https://files.pythonhosted.org/packages/4b/86/17a85475e218e05225a4f6d65237b12b280596e04520df0e3d8841c4eae3/wrapt-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:df6e3a36170cda0d313be50fe5065948e7f12f3a181b38cbc262e9f2ee4824e1", size = 103415, upload-time = "2026-10-14T00:38:27.409Z" }, + { url = "https://files.pythonhosted.org/packages/27/1c/495b3aebbbe5aebf52ae5f9e8ddd0e072a412b9f5a9bc68e5eba43fa26ba/wrapt-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bc5c0203d383403043fb86c964bd0bab4fcbfb26004ff4bb9c6d02ebc1d608ae", size = 109940, upload-time = "2026-10-14T00:38:29.145Z" }, + { url = "https://files.pythonhosted.org/packages/28/4d/030ecd98da4d052c264290c4fb9f984706c9a19026154c833580e8624a05/wrapt-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:a424e8a9776c06aef6313af1d0e3fe6e0838af4241d0c09eb0a3b46f2c9a5ff3", size = 106214, upload-time = "2026-10-14T00:38:30.795Z" }, + { url = "https://files.pythonhosted.org/packages/90/2b/eec5745baaad284fa19232f47796914134e1ea2dd21e289b012aa7981323/wrapt-2.5.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a18e63910252eb75d8806b4baefbc3a03612502f63eab042e3741b00b719f043", size = 106441, upload-time = "2026-10-14T00:38:32.504Z" }, + { url = "https://files.pythonhosted.org/packages/29/f3/976b0f014a08654289358d41a799c2d24642151b091cfe18a8b91766ee28/wrapt-2.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:183bf0bb893f783c9d22f953cb01fababb9f618e098763f8e66337b575b0647a", size = 106411, upload-time = "2026-10-14T00:38:34.338Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f4/4b94583d9bec0ff0573a5f10fab295675e3b9a64b701c4609b1a1982c390/wrapt-2.5.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1e823aecb3746b8f9e0aee2e1413887871ee2f5c502a3e0ef8d466dbd4adde1", size = 252017, upload-time = "2026-10-14T00:38:36.142Z" }, + { url = "https://files.pythonhosted.org/packages/5e/3c/4f9ba033343b2935a453188f97866f0bd4f7748748ab307aaefb18be3a0a/wrapt-2.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bde5d1b37101b1e9dd3da1f35072e2e7028e9c5e3511f7d76d3fdd4d071b7663", size = 251030, upload-time = "2026-10-14T00:38:38.003Z" }, + { url = "https://files.pythonhosted.org/packages/69/a1/704c761913be404ed893d05702eeda5ff96c5d8448271c28b80101202bcb/wrapt-2.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12d3d2b9d6553df6e2421ab99e1cc5413509076788f57fcb3169f5ce100a19d1", size = 232875, upload-time = "2026-10-14T00:38:40.124Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/779ca20fb8c70238069efd0a2b60ea3da450e57c4f103748db0247f52f3a/wrapt-2.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:521bd5ef2a33171fac08a0a302d51a983c19c3519406c1ee8da7ce29285488da", size = 248302, upload-time = "2026-10-14T00:38:42.138Z" }, + { url = "https://files.pythonhosted.org/packages/03/02/80e13786204ce8e1002edb66d06a3194906c0dbbc038bce7bbda426d0f2b/wrapt-2.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:129cab3c7b21e68e693c2819a95c47f3b1c41a834b931154688c83b6aef6bdab", size = 229839, upload-time = "2026-10-14T00:38:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d1/14c0d041375ae5d0a12445b5c5bc61b109df954cd5bfb852736cd4281cbd/wrapt-2.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:8a7c078323e6e1534968cb85488c5eb7ee2b9bbd0f8a291095213a763da40dab", size = 239634, upload-time = "2026-10-14T00:38:45.896Z" }, + { url = "https://files.pythonhosted.org/packages/97/6e/dacc92526fbed1013eb903406ce961da6ff2a1e66b7349a253f439b979aa/wrapt-2.5.1-cp315-cp315-win32.whl", hash = "sha256:736c1de0230c6d24327b14684794214167b2c5ebb6332e28a10f504641b600df", size = 100931, upload-time = "2026-10-14T00:38:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e4/84bbd88554052958ecbbb481a75559b1e40753aba52ec86e3e1efb50ccf0/wrapt-2.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:69fd0fbb3daf7c8c6f5e062847a0061f880f347374d74cf1daba57220fb64cd0", size = 106252, upload-time = "2026-10-14T00:38:49.511Z" }, + { url = "https://files.pythonhosted.org/packages/b7/98/98d4c4524e8af70ccf35b66864be29ea9d232e5a918efc1dbcf5c87a039d/wrapt-2.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:051220e5071fdfb1a6678707c8abb7bbf4824d40f99758394b2b4d64855fb284", size = 103980, upload-time = "2026-10-14T00:38:51.384Z" }, + { url = "https://files.pythonhosted.org/packages/75/d9/4b242519d6d29eabb73cb9e50e645e014eb2c13f022601151953b3e81946/wrapt-2.5.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:711e73da3d7983547fc9dd208973b6b0c52640822f5d477910ba24622df6ba64", size = 109538, upload-time = "2026-10-14T00:38:53.124Z" }, + { url = "https://files.pythonhosted.org/packages/58/05/e434f56fcceaafb251cc56c03107ae278e99158466b49f4146f7b33a2532/wrapt-2.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:5be9816d9de88f02fce23cf55f392403411d9bd9c7ae57fdc965a43b22e2de5e", size = 109874, upload-time = "2026-10-14T00:38:54.941Z" }, + { url = "https://files.pythonhosted.org/packages/23/09/d2c0b34d02804018225279a157307b873c8d0f8452790efdd7f0004387a8/wrapt-2.5.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b3f410c416752e1dba53d361e2e6562f22c2c3ec855740dfa5836e061b22571", size = 284866, upload-time = "2026-10-14T00:38:57.081Z" }, + { url = "https://files.pythonhosted.org/packages/71/6f/2b56319c0565d9a6b63eeba2f11f3e699dc324aaa460f1aae079bd4507b0/wrapt-2.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:094b847491b813b6e6c1775e03770930d75078c0821adf929ac712830951ef25", size = 295154, upload-time = "2026-10-14T00:38:59.007Z" }, + { url = "https://files.pythonhosted.org/packages/3c/1b/9ac4238a1a839457d6b687b9e9c35d57ba1b2050a42d6bf8c93176bc1bdf/wrapt-2.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:26d8ea2ec6818aeb656bd8a9e745a6f1fb0edfcd8f54291ccd94f62eb5f5e3bd", size = 264914, upload-time = "2026-10-14T00:39:01.154Z" }, + { url = "https://files.pythonhosted.org/packages/4f/95/9faed8e5f6e5431edd36b2cfb4f305df520c197ba3639e1c78d11c70a068/wrapt-2.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0a526227efe17dd94bd16b123d170f879bce42c15f10eb92495a745f54caa943", size = 290238, upload-time = "2026-10-14T00:39:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/b5/52/cae26590ef8ee46b55aa8b211c507f6e5ec0fbd241a6730bf7da024b5dab/wrapt-2.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:36d7d0ad593c4f1a651e4032de834db59aee1a929ee396cd483895b673328e51", size = 262208, upload-time = "2026-10-14T00:39:05.008Z" }, + { url = "https://files.pythonhosted.org/packages/00/f3/34e5008307be4169592e99de52946cd8800b98097e2a145da11484c7b3e4/wrapt-2.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:89d9a8607b7028054bb6fd01d437f205534a5d59d53c3665d15949a99a2fce0d", size = 280005, upload-time = "2026-10-14T00:39:07.048Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f9/64e000aa84a88c52a481c7c8b011c80a8ae60bd5f68598e567a918a624a0/wrapt-2.5.1-cp315-cp315t-win32.whl", hash = "sha256:ad81bf81b0a0b6c6ec74169638202851962843e86749570c463eecc55072f93b", size = 103378, upload-time = "2026-10-14T00:39:09.336Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e4/69efa7c6e8535c5188e041ac278079949fb2daaa97e6f08beb91cf31b3d1/wrapt-2.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d5b665a43fe0d3b390cbdd3c003d61c92fa07bd5e3fb1ed3f47920c2d03cd9fd", size = 109928, upload-time = "2026-10-14T00:39:11.156Z" }, + { url = "https://files.pythonhosted.org/packages/13/77/6e414b3388b9f1ecb76107ef4a2aae501f1bdfcab85c8e34ef78f7db22db/wrapt-2.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:6405ff2160af9d59132ebb076eda0304db44d9d09809582932412ef7c0788a36", size = 106226, upload-time = "2026-10-14T00:39:13.061Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0c/7da7513ddcc8f1d831ec4bfbedc9f7f174ecb91042bc16916fc1e0d06b22/wrapt-2.5.1-py3-none-any.whl", hash = "sha256:c6e6c226b1ca5402d7ae5fb34a0d21f1b49124fe4200e5884d1e19e53c47ac1d", size = 81849, upload-time = "2026-10-14T00:39:37.441Z" }, +] diff --git a/web/Cargo.lock b/web/Cargo.lock new file mode 100644 index 0000000..c2ef352 --- /dev/null +++ b/web/Cargo.lock @@ -0,0 +1,568 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gloo-net" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580" +dependencies = [ + "futures-channel", + "futures-core", + "futures-sink", + "gloo-utils", + "http", + "js-sys", + "pin-project", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "gloo-utils" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "html-escape" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kca-web" +version = "0.1.0" +dependencies = [ + "base64", + "console_error_panic_hook", + "gloo-net", + "gloo-timers", + "js-sys", + "serde", + "serde_json", + "sycamore", + "sycamore-router", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "sycamore" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa76a5ba796aa25357d70adf027a171b9da18478cf1ab64a9ed0c06f8fca0e49" +dependencies = [ + "sycamore-core", + "sycamore-macro", + "sycamore-reactive", + "sycamore-web", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "sycamore-core" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fce3012a86a7d1ae7ca06db4d2172747a574904eed10808286071cef469f975" +dependencies = [ + "hashbrown", + "paste", + "sycamore-reactive", +] + +[[package]] +name = "sycamore-macro" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc13361313005ba633fbf1f973c2f2a2e9f31cda659f102eb362361065b450b" +dependencies = [ + "proc-macro2", + "quote", + "sycamore-view-parser", + "syn 3.0.9", +] + +[[package]] +name = "sycamore-reactive" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aca468d1209c2d1ae3059fb93aa005e67bbe1f17375a3c0a65135e98a1b58829" +dependencies = [ + "paste", + "slotmap", + "smallvec", + "wasm-bindgen", +] + +[[package]] +name = "sycamore-router" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6b53bacb0dfbb0665613b758ae24caf8ca8b221387077aee34bd76fefd7b77f" +dependencies = [ + "sycamore", + "sycamore-router-macro", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "sycamore-router-macro" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4912026c5892851b743a601d7d52275b325e0f2cd245f856f646259fbef06730" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sycamore-view-parser" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11fcefa71d9fe1100c90903c837224848441712cd90f34570bb44327a5598ca" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sycamore-web" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7fdad6ed1ec34d7e6a2699b4bf76059b3c56f97b9b94f1ab69edc9b04696f4" +dependencies = [ + "html-escape", + "js-sys", + "once_cell", + "paste", + "smallvec", + "sycamore-core", + "sycamore-macro", + "sycamore-reactive", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/web/Cargo.toml b/web/Cargo.toml new file mode 100644 index 0000000..4e5d709 --- /dev/null +++ b/web/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "kca-web" +version = "0.1.0" +edition = "2021" +description = "Sycamore/WASM frontend for KCA" + +[dependencies] +sycamore = "0.9" +sycamore-router = "0.9" +gloo-net = "0.6" +gloo-timers = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = [ + "Window", + "Document", + "Location", + "Storage", + "HtmlInputElement", + "HtmlSelectElement", + "HtmlTextAreaElement", + "HtmlAnchorElement", + "HtmlElement", + "Element", + "Event", + "EventTarget", + "File", + "FileList", + "Blob", + "BlobPropertyBag", + "Url", + "Navigator", + "Clipboard", + "HtmlDetailsElement", + "HtmlFormElement", + "DomTokenList", +] } +js-sys = "0.3" +base64 = "0.22" +console_error_panic_hook = "0.1" + +[profile.release] +opt-level = "z" +lto = true diff --git a/web/Trunk.toml b/web/Trunk.toml new file mode 100644 index 0000000..292c734 --- /dev/null +++ b/web/Trunk.toml @@ -0,0 +1,12 @@ +# Development server for the KCA SPA. Proxies the backend prefixes to the +# app on :8080 so requests are same-origin and the session cookie works. +[serve] +port = 8000 + +[[proxy]] +rewrite = "/api" +backend = "http://127.0.0.1:8080/api" + +[[proxy]] +rewrite = "/auth" +backend = "http://127.0.0.1:8080/auth" diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..9ef5fdd --- /dev/null +++ b/web/index.html @@ -0,0 +1,23 @@ + + + + + + KCA — Key & Certificate Authority + + + + + + + + + + + + + + + + + diff --git a/web/pwa/icons/icon.svg b/web/pwa/icons/icon.svg new file mode 100644 index 0000000..ecd9da6 --- /dev/null +++ b/web/pwa/icons/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/pwa/manifest.webmanifest b/web/pwa/manifest.webmanifest new file mode 100644 index 0000000..dd03828 --- /dev/null +++ b/web/pwa/manifest.webmanifest @@ -0,0 +1,18 @@ +{ + "name": "KCA — Key & Certificate Authority", + "short_name": "KCA", + "description": "Web PKI management: keys, CSRs, certificates, CRLs and templates.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#0a1720", + "theme_color": "#0a1720", + "icons": [ + { + "src": "/icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/web/pwa/sw.js b/web/pwa/sw.js new file mode 100644 index 0000000..2781af6 --- /dev/null +++ b/web/pwa/sw.js @@ -0,0 +1,26 @@ +/* KCA app-shell service worker. It caches only the static shell, never API + * responses: certificate and key material must not be served stale. */ +const CACHE = "kca-shell-v1"; +const SHELL = ["/", "/manifest.webmanifest", "/icons/icon.svg"]; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)).then(() => self.skipWaiting())); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))) + .then(() => self.clients.claim()) + ); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") return; + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/auth/")) return; + event.respondWith( + caches.match(request).then((cached) => cached || fetch(request).catch(() => caches.match("/"))) + ); +}); diff --git a/web/src/api.rs b/web/src/api.rs new file mode 100644 index 0000000..951df6e --- /dev/null +++ b/web/src/api.rs @@ -0,0 +1,445 @@ +//! REST client for the KCA API. + +use gloo_net::http::{Request, Response}; +use serde::de::DeserializeOwned; +use serde_json::json; + +use crate::auth; +use crate::model::{ + ApiTokensPage, AuthProvidersPage, CertificateDetail, CertificatesPage, CreatedApiToken, + CrlDetail, CrlsPage, ExportPayload, ImportPreview, ImportResult, KeyDetail, KeysPage, Me, + OptionsPage, RequestDetail, RequestsPage, RevocationsPage, SearchPage, SummaryPage, + TemplateSummary, TemplatesPage, VaultDetail, VaultsPage, +}; + +#[derive(serde::Deserialize)] +struct ErrorEnvelope { + #[serde(default)] + error: String, +} + +fn server_error(status: u16) -> String { + format!("server returned {status}") +} + +async fn error_message(response: Response) -> String { + let status = response.status(); + match response.json::().await { + Ok(envelope) if !envelope.error.is_empty() => envelope.error, + _ => server_error(status), + } +} + +async fn check(response: Response) -> Result { + if response.status() == 401 { + auth::redirect_to_login(); + return Err(LOGIN_REQUIRED.to_string()); + } + if !response.ok() { + return Err(error_message(response).await); + } + response.json().await.map_err(|e| e.to_string()) +} + +async fn send(method: &str, url: &str, body: Option<&serde_json::Value>) -> Result { + let builder = match method { + "POST" => Request::post(url), + "PUT" => Request::put(url), + "PATCH" => Request::patch(url), + "DELETE" => Request::delete(url), + _ => Request::get(url), + }; + let request = match body { + Some(payload) => builder.json(payload).map_err(|e| e.to_string())?, + None => builder.build().map_err(|e| e.to_string())?, + }; + request.send().await.map_err(|e| e.to_string()) +} + +async fn expect_empty(response: Response) -> Result<(), String> { + if response.status() == 401 { + auth::redirect_to_login(); + return Err(LOGIN_REQUIRED.to_string()); + } + if !response.ok() { + return Err(error_message(response).await); + } + Ok(()) +} + +pub const LOGIN_REQUIRED: &str = "login required"; + +fn url_encode(value: &str) -> String { + value + .bytes() + .map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + (byte as char).to_string() + } + _ => format!("%{byte:02X}"), + }) + .collect() +} + +// --------------------------------------------------------------------------- +// Auth / identity +// --------------------------------------------------------------------------- + +pub async fn me_optional() -> Result, String> { + let response = Request::get("/api/me").send().await.map_err(|e| e.to_string())?; + if response.status() == 401 { + return Ok(None); + } + if !response.ok() { + return Err(server_error(response.status())); + } + response.json().await.map(Some).map_err(|e| e.to_string()) +} + +pub async fn me() -> Result { + match me_optional().await? { + Some(user) => Ok(user), + None => { + auth::redirect_to_login(); + Err(LOGIN_REQUIRED.to_string()) + } + } +} + +pub async fn auth_providers() -> Result { + let response = Request::get("/api/auth/providers").send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn tokens() -> Result { + let response = Request::get("/api/tokens").send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn create_token(name: &str, expires_at: Option<&str>) -> Result { + let response = send("POST", "/api/tokens", Some(&json!({"name": name, "expires_at": expires_at}))).await?; + check(response).await +} + +pub async fn delete_token(id: &str) -> Result<(), String> { + let response = send("DELETE", &format!("/api/tokens/{id}"), None).await?; + expect_empty(response).await +} + +// --------------------------------------------------------------------------- +// Vaults +// --------------------------------------------------------------------------- + +pub async fn vaults() -> Result { + let response = Request::get("/api/vaults").send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn vault(id: &str) -> Result { + let response = Request::get(&format!("/api/vaults/{id}")).send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn create_vault(name: &str, description: &str) -> Result { + let response = send("POST", "/api/vaults", Some(&json!({"name": name, "description": description}))).await?; + check(response).await +} + +pub async fn update_vault(id: &str, name: &str, description: &str, version: i64) -> Result { + let response = send( + "PATCH", + &format!("/api/vaults/{id}"), + Some(&json!({"name": name, "description": description, "version": version})), + ) + .await?; + check(response).await +} + +pub async fn delete_vault(id: &str) -> Result<(), String> { + let response = send("DELETE", &format!("/api/vaults/{id}"), None).await?; + expect_empty(response).await +} + +pub async fn vault_settings(id: &str) -> Result { + let response = Request::get(&format!("/api/vaults/{id}/settings")).send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn update_vault_settings(id: &str, settings: &serde_json::Value, version: i64) -> Result { + let response = send( + "PATCH", + &format!("/api/vaults/{id}/settings"), + Some(&json!({"settings": settings, "version": version})), + ) + .await?; + check(response).await +} + +pub async fn vault_summary(id: &str) -> Result { + let response = Request::get(&format!("/api/vaults/{id}/summary")).send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn options(id: &str) -> Result { + let response = Request::get(&format!("/api/vaults/{id}/options")).send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn search(id: &str, query: &str, kind: &str) -> Result { + let url = format!( + "/api/vaults/{id}/search?q={}&type={}", + url_encode(query), + url_encode(kind) + ); + let response = Request::get(&url).send().await.map_err(|e| e.to_string())?; + check(response).await +} + +// --------------------------------------------------------------------------- +// Keys +// --------------------------------------------------------------------------- + +pub async fn keys(vault_id: &str) -> Result { + let response = Request::get(&format!("/api/vaults/{vault_id}/keys")).send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn key(vault_id: &str, key_id: &str) -> Result { + let response = Request::get(&format!("/api/vaults/{vault_id}/keys/{key_id}")).send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn generate_key(vault_id: &str, payload: &serde_json::Value) -> Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/keys"), Some(payload)).await?; + check(response).await +} + +pub async fn update_key(vault_id: &str, key_id: &str, payload: &serde_json::Value) -> Result { + let response = send("PATCH", &format!("/api/vaults/{vault_id}/keys/{key_id}"), Some(payload)).await?; + check(response).await +} + +pub async fn delete_key(vault_id: &str, key_id: &str) -> Result<(), String> { + let response = send("DELETE", &format!("/api/vaults/{vault_id}/keys/{key_id}"), None).await?; + expect_empty(response).await +} + +pub async fn export_key(vault_id: &str, key_id: &str, payload: &serde_json::Value) -> Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/keys/{key_id}/export"), Some(payload)).await?; + check(response).await +} + +// --------------------------------------------------------------------------- +// Requests +// --------------------------------------------------------------------------- + +pub async fn requests(vault_id: &str) -> Result { + let response = Request::get(&format!("/api/vaults/{vault_id}/requests")).send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn request(vault_id: &str, request_id: &str) -> Result { + let response = Request::get(&format!("/api/vaults/{vault_id}/requests/{request_id}")).send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn create_request(vault_id: &str, payload: &serde_json::Value) -> Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/requests"), Some(payload)).await?; + check(response).await +} + +pub async fn update_request(vault_id: &str, request_id: &str, payload: &serde_json::Value) -> Result { + let response = send("PATCH", &format!("/api/vaults/{vault_id}/requests/{request_id}"), Some(payload)).await?; + check(response).await +} + +pub async fn delete_request(vault_id: &str, request_id: &str) -> Result<(), String> { + let response = send("DELETE", &format!("/api/vaults/{vault_id}/requests/{request_id}"), None).await?; + expect_empty(response).await +} + +pub async fn export_request(vault_id: &str, request_id: &str, payload: &serde_json::Value) -> Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/requests/{request_id}/export"), Some(payload)).await?; + check(response).await +} + +pub async fn transform_request(vault_id: &str, request_id: &str, action: &str) -> Result { + let response = send( + "POST", + &format!("/api/vaults/{vault_id}/requests/{request_id}/transform/{action}"), + Some(&json!({})), + ) + .await?; + check(response).await +} + +// --------------------------------------------------------------------------- +// Certificates +// --------------------------------------------------------------------------- + +pub async fn certificates(vault_id: &str) -> Result { + let response = Request::get(&format!("/api/vaults/{vault_id}/certificates")).send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn certificate(vault_id: &str, cert_id: &str) -> Result { + let response = Request::get(&format!("/api/vaults/{vault_id}/certificates/{cert_id}")).send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn create_certificate(vault_id: &str, payload: &serde_json::Value) -> Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/certificates"), Some(payload)).await?; + check(response).await +} + +pub async fn update_certificate(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result { + let response = send("PATCH", &format!("/api/vaults/{vault_id}/certificates/{cert_id}"), Some(payload)).await?; + check(response).await +} + +pub async fn delete_certificate(vault_id: &str, cert_id: &str) -> Result<(), String> { + let response = send("DELETE", &format!("/api/vaults/{vault_id}/certificates/{cert_id}"), None).await?; + expect_empty(response).await +} + +pub async fn export_certificate(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/export"), Some(payload)).await?; + check(response).await +} + +pub async fn renew_certificate(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/renew"), Some(payload)).await?; + check(response).await +} + +pub async fn revoke_certificate(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/revoke"), Some(payload)).await?; + check(response).await +} + +pub async fn ca_info(vault_id: &str, cert_id: &str) -> Result { + let response = Request::get(&format!("/api/vaults/{vault_id}/certificates/{cert_id}/ca")).send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn update_ca(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result { + let response = send("PATCH", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/ca"), Some(payload)).await?; + check(response).await +} + +pub async fn revocations(vault_id: &str, cert_id: &str) -> Result { + let response = Request::get(&format!("/api/vaults/{vault_id}/certificates/{cert_id}/revocations")).send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn add_revocation(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/revocations"), Some(payload)).await?; + check(response).await +} + +pub async fn update_revocation(vault_id: &str, cert_id: &str, rev_id: &str, payload: &serde_json::Value) -> Result { + let response = send("PATCH", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/revocations/{rev_id}"), Some(payload)).await?; + check(response).await +} + +pub async fn delete_revocation(vault_id: &str, cert_id: &str, rev_id: &str) -> Result<(), String> { + let response = send("DELETE", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/revocations/{rev_id}"), None).await?; + expect_empty(response).await +} + +pub async fn generate_crl(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/crl"), Some(payload)).await?; + check(response).await +} + +pub async fn tls_auth(vault_id: &str, cert_id: &str, payload: &serde_json::Value) -> Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/{cert_id}/tls-auth"), Some(payload)).await?; + check(response).await +} + +pub async fn transform_certificate(vault_id: &str, cert_id: &str, action: &str) -> Result { + let response = send( + "POST", + &format!("/api/vaults/{vault_id}/certificates/{cert_id}/transform/{action}"), + Some(&json!({})), + ) + .await?; + check(response).await +} + +// --------------------------------------------------------------------------- +// CRLs +// --------------------------------------------------------------------------- + +pub async fn crls(vault_id: &str) -> Result { + let response = Request::get(&format!("/api/vaults/{vault_id}/crls")).send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn crl(vault_id: &str, crl_id: &str) -> Result { + let response = Request::get(&format!("/api/vaults/{vault_id}/crls/{crl_id}")).send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn delete_crl(vault_id: &str, crl_id: &str) -> Result<(), String> { + let response = send("DELETE", &format!("/api/vaults/{vault_id}/crls/{crl_id}"), None).await?; + expect_empty(response).await +} + +pub async fn export_crl(vault_id: &str, crl_id: &str, payload: &serde_json::Value) -> Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/crls/{crl_id}/export"), Some(payload)).await?; + check(response).await +} + +// --------------------------------------------------------------------------- +// Templates +// --------------------------------------------------------------------------- + +pub async fn templates(vault_id: &str) -> Result { + let response = Request::get(&format!("/api/vaults/{vault_id}/templates")).send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn template(vault_id: &str, template_id: &str) -> Result { + let response = Request::get(&format!("/api/vaults/{vault_id}/templates/{template_id}")).send().await.map_err(|e| e.to_string())?; + check(response).await +} + +pub async fn create_template(vault_id: &str, payload: &serde_json::Value) -> Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/templates"), Some(payload)).await?; + check(response).await +} + +pub async fn update_template(vault_id: &str, template_id: &str, payload: &serde_json::Value) -> Result { + let response = send("PATCH", &format!("/api/vaults/{vault_id}/templates/{template_id}"), Some(payload)).await?; + check(response).await +} + +pub async fn delete_template(vault_id: &str, template_id: &str) -> Result<(), String> { + let response = send("DELETE", &format!("/api/vaults/{vault_id}/templates/{template_id}"), None).await?; + expect_empty(response).await +} + +pub async fn apply_template(vault_id: &str, template_id: &str, payload: &serde_json::Value) -> Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/templates/{template_id}/apply"), Some(payload)).await?; + check(response).await +} + +pub async fn export_template(vault_id: &str, template_id: &str, payload: &serde_json::Value) -> Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/templates/{template_id}/export"), Some(payload)).await?; + check(response).await +} + +// --------------------------------------------------------------------------- +// Import +// --------------------------------------------------------------------------- + +pub async fn import_items(vault_id: &str, payload: &serde_json::Value) -> Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/import"), Some(payload)).await?; + check(response).await +} + +pub async fn import_commit(vault_id: &str, payload: &serde_json::Value) -> Result { + let response = send("POST", &format!("/api/vaults/{vault_id}/import"), Some(payload)).await?; + check(response).await +} diff --git a/web/src/auth.rs b/web/src/auth.rs new file mode 100644 index 0000000..2a148e7 --- /dev/null +++ b/web/src/auth.rs @@ -0,0 +1,56 @@ +//! Login redirect and return-to-page handling. +use gloo_timers::callback::Timeout; +use sycamore_router::navigate_replace; + +const RETURN_TO_KEY: &str = "kca.login.return_to"; + +fn storage() -> Option { + web_sys::window()?.session_storage().ok().flatten() +} + +fn current_path() -> String { + match web_sys::window() { + Some(window) => { + let location = window.location(); + let path = location.pathname().unwrap_or_default(); + let search = location.search().unwrap_or_default(); + format!("{path}{search}") + } + None => "/".to_string(), + } +} + +fn is_restorable(path: &str) -> bool { + !path.is_empty() + && path.starts_with('/') + && !path.starts_with("/api/") + && !path.starts_with("/auth/") + && path != "/login" +} + +pub fn redirect_to_login() { + let path = current_path(); + if is_restorable(&path) { + if let Some(storage) = storage() { + let _ = storage.set_item(RETURN_TO_KEY, &path); + } + } + Timeout::new(0, move || { + if current_path() != "/login" { + navigate_replace("/login"); + } + }) + .forget(); +} + +pub fn saved_return_to() -> Option { + storage()?.get_item(RETURN_TO_KEY).ok().flatten() +} + +pub fn clear_return_to() { + if let Some(storage) = storage() { + let _ = storage.remove_item(RETURN_TO_KEY); + } +} + +pub const LOGOUT_URL: &str = "/auth/logout"; diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs new file mode 100644 index 0000000..b6ff70d --- /dev/null +++ b/web/src/components/mod.rs @@ -0,0 +1,3 @@ +pub mod nav; +pub mod toast; +pub mod ui; diff --git a/web/src/components/nav.rs b/web/src/components/nav.rs new file mode 100644 index 0000000..2e73de1 --- /dev/null +++ b/web/src/components/nav.rs @@ -0,0 +1,88 @@ +//! Navigation bar and user slot. +use sycamore::prelude::*; + +use crate::auth::LOGOUT_URL; +use crate::model::Me; + +pub use super::ui::spinner; + +fn nav_link(href: String, label: &'static str, active: &'static str) -> View { + let class = if href == active { "nav-link active" } else { "nav-link" }; + view! { + li(class="nav-item") { + a(class=class, href=href) { (label) } + } + } +} + +pub fn app_nav(active: &'static str, vault_id: Option<&'static str>, whoami: impl Fn() -> View + 'static) -> View { + let open = create_signal(false); + let toggle = move |_| open.set(!open.get()); + let collapse_class = move || { + if open.get() { + "collapse navbar-collapse show" + } else { + "collapse navbar-collapse" + } + }; + let expanded = move || if open.get() { "true" } else { "false" }; + let vault_links = move || match vault_id { + Some(id) => view! { + (nav_link(format!("/vaults/{id}"), "Overview", active)) + (nav_link(format!("/vaults/{id}/certificates"), "Certificates", active)) + (nav_link(format!("/vaults/{id}/requests"), "Requests", active)) + (nav_link(format!("/vaults/{id}/keys"), "Keys", active)) + (nav_link(format!("/vaults/{id}/crls"), "CRLs", active)) + (nav_link(format!("/vaults/{id}/templates"), "Templates", active)) + (nav_link(format!("/vaults/{id}/search"), "Search", active)) + }, + None => view! { + (nav_link("/".to_string(), "Vaults", active)) + }, + }; + view! { + nav(class="navbar navbar-expand-lg") { + div(class="container-fluid") { + a(class="navbar-brand", href="/") { + span(class="brand-glyph") { "⬡" } " KCA" + } + button(class="navbar-toggler", r#type="button", + aria-label="Toggle navigation", + aria-controls="app-nav", + aria-expanded=expanded, + on:click=toggle) { + span(class="navbar-toggler-icon") {} + } + div(id="app-nav", class=collapse_class) { + ul(class="navbar-nav me-auto") { + (vault_links) + } + (whoami()) + } + } + } + } +} + +pub fn whoami(me: &Me) -> View { + let name = me.display_name.clone(); + let initial = name.chars().next().unwrap_or('?').to_string(); + let picture = me.picture.clone(); + view! { + span(class="navbar-text d-flex align-items-center gap-3") { + span(class="d-flex align-items-center gap-2") { + (if let Some(picture) = picture { + view! { img(class="avatar", src=picture, alt="") } + } else { + view! { + span(class="avatar bg-secondary d-inline-flex align-items-center justify-content-center") { + (initial) + } + } + }) + span() { (name) } + } + a(class="btn btn-sm btn-outline-light", href=LOGOUT_URL, rel="external") { "Sign out" } + } + } +} diff --git a/web/src/components/toast.rs b/web/src/components/toast.rs new file mode 100644 index 0000000..a8e0d94 --- /dev/null +++ b/web/src/components/toast.rs @@ -0,0 +1,20 @@ +//! A transient error/success toast. +use gloo_timers::callback::Timeout; +use sycamore::prelude::*; + +const TOAST_MS: u32 = 10_000; + +pub fn toast(message: Signal>, success: bool) -> View { + create_effect(move || { + if message.get_clone().is_some() { + let timeout = Timeout::new(TOAST_MS, move || message.set(None)); + on_cleanup(move || drop(timeout)); + } + }); + let class = if success { "app-toast success" } else { "app-toast" }; + view! { + (move || message.get_clone().map(|text| view! { + div(class=class, role="alert") { (text) } + })) + } +} diff --git a/web/src/components/ui.rs b/web/src/components/ui.rs new file mode 100644 index 0000000..4480175 --- /dev/null +++ b/web/src/components/ui.rs @@ -0,0 +1,91 @@ +//! Shared small UI fragments. +use sycamore::prelude::*; + +pub fn toast_error(message: Signal>) -> View { + crate::components::toast::toast(message, false) +} + +pub fn spinner() -> View { + view! { + div(class="d-flex justify-content-center p-5") { + div(class="spinner-border text-warning", role="status") { + span(class="visually-hidden") { "Loading…" } + } + } + } +} + +pub fn empty_state(message: impl Into) -> View { + let message = message.into(); + view! { p(class="empty-state") { (message) } } +} + +pub fn page_header(title: impl Into, subtitle: impl Into, actions: View) -> View { + let title = title.into(); + let subtitle = subtitle.into(); + view! { + div(class="d-flex justify-content-between align-items-end flex-wrap gap-2 mb-4") { + div() { + h1(class="h3 mb-1") { (title) } + p(class="text-secondary mb-0") { (subtitle) } + } + div(class="d-flex gap-2 flex-wrap") { (actions) } + } + } +} + +pub fn error_alert(message: Signal>) -> View { + view! { + (if message.get_clone().is_some() { + view! { + div(class="alert alert-danger py-2 mb-3") { (message.get_clone().unwrap_or_default()) } + } + } else { + view! {} + }) + } +} + +pub fn status_badge(label: impl Into, class: impl Into) -> View { + let label = label.into(); + let class = class.into(); + view! { span(class=format!("badge {class}")) { (label) } } +} + +pub fn copy_button(text: String, label: &'static str) -> View { + let feedback = create_signal(None::); + let click = move |_| { + let value = text.clone(); + let feedback = feedback; + wasm_bindgen_futures::spawn_local(async move { + match crate::dom::copy_text(&value).await { + Ok(()) => feedback.set(Some("Copied".to_string())), + Err(error) => feedback.set(Some(error)), + } + }); + }; + view! { + div(class="d-inline-flex align-items-center gap-2") { + button(class="btn btn-sm btn-outline-light", r#type="button", on:click=click) { (label) } + (if let Some(text) = feedback.get_clone() { + view! { span(class="text-secondary small") { (text) } } + } else { + view! {} + }) + } + } +} + +pub fn kv_row(label: impl Into, value: View) -> View { + let label = label.into(); + view! { + div(class="row g-1 py-1 border-bottom border-secondary-subtle") { + div(class="col-sm-4 text-secondary small") { (label) } + div(class="col-sm-8") { (value) } + } + } +} + +pub fn mono(text: String) -> View { + view! { span(class="mono fingerprint") { (text) } } +} diff --git a/web/src/dom.rs b/web/src/dom.rs new file mode 100644 index 0000000..c92d620 --- /dev/null +++ b/web/src/dom.rs @@ -0,0 +1,119 @@ +//! Small DOM helpers shared by the pages. +use base64::Engine; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::JsFuture; + +pub fn input_value(event: &web_sys::Event) -> String { + event + .target() + .and_then(|target| target.dyn_into::().ok()) + .map(|input| input.value()) + .unwrap_or_default() +} + +pub fn input_checked(event: &web_sys::Event) -> bool { + event + .target() + .and_then(|target| target.dyn_into::().ok()) + .map(|input| input.checked()) + .unwrap_or(false) +} + +pub fn textarea_value(event: &web_sys::Event) -> String { + event + .target() + .and_then(|target| target.dyn_into::().ok()) + .map(|input| input.value()) + .unwrap_or_default() +} + +pub fn select_value(event: &web_sys::Event) -> String { + event + .target() + .and_then(|target| target.dyn_into::().ok()) + .map(|select| select.value()) + .unwrap_or_default() +} + +pub fn input_file(event: &web_sys::Event) -> Option { + event + .target() + .and_then(|target| target.dyn_into::().ok()) + .and_then(|input| input.files()) + .and_then(|files| files.get(0)) +} + +pub fn reset_file_input(event: &web_sys::Event) { + if let Some(input) = event + .target() + .and_then(|target| target.dyn_into::().ok()) + { + input.set_value(""); + } +} + +pub async fn read_file(file: &web_sys::File) -> Result, String> { + let promise = file.array_buffer(); + let buffer = JsFuture::from(promise) + .await + .map_err(|_| "the selected file could not be read".to_string())?; + Ok(js_sys::Uint8Array::new(&buffer).to_vec()) +} + +pub async fn read_file_base64(file: &web_sys::File) -> Result { + let bytes = read_file(file).await?; + Ok(base64::engine::general_purpose::STANDARD.encode(bytes)) +} + +pub fn confirm(message: &str) -> bool { + web_sys::window() + .map(|window| window.confirm_with_message(message).unwrap_or(false)) + .unwrap_or(false) +} + +pub async fn copy_text(text: &str) -> Result<(), String> { + let window = web_sys::window().ok_or("no browser window")?; + let promise = window.navigator().clipboard().write_text(text); + JsFuture::from(promise) + .await + .map(|_| ()) + .map_err(|_| "the browser blocked clipboard access".to_string()) +} + +/// Decode an API export payload and trigger a browser download. +pub fn download_base64(filename: &str, content_type: &str, content_base64: &str) -> Result<(), String> { + let bytes = base64::engine::general_purpose::STANDARD + .decode(content_base64) + .map_err(|_| "the exported file is not valid base64".to_string())?; + download_bytes(filename, content_type, &bytes) +} + +pub fn download_bytes(filename: &str, content_type: &str, bytes: &[u8]) -> Result<(), String> { + let window = web_sys::window().ok_or("no browser window")?; + let document = window.document().ok_or("no document")?; + let array = js_sys::Uint8Array::from(bytes); + let parts = js_sys::Array::new(); + parts.push(&array.buffer()); + let options = web_sys::BlobPropertyBag::new(); + options.set_type(content_type); + let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options) + .map_err(|_| "could not create the download".to_string())?; + let url = web_sys::Url::create_object_url_with_blob(&blob) + .map_err(|_| "could not create the download URL".to_string())?; + let anchor = document + .create_element("a") + .map_err(|_| "could not create the download link".to_string())? + .dyn_into::() + .map_err(|_| "could not create the download link".to_string())?; + anchor.set_href(&url); + anchor.set_download(filename); + anchor.click(); + web_sys::Url::revoke_object_url(&url).ok(); + Ok(()) +} + +pub fn navigate(path: &str) { + if let Some(window) = web_sys::window() { + let _ = window.location().set_href(path); + } +} diff --git a/web/src/main.rs b/web/src/main.rs new file mode 100644 index 0000000..8daaeb7 --- /dev/null +++ b/web/src/main.rs @@ -0,0 +1,118 @@ +//! KCA: a web reimplementation of XCA's software-key PKI workflows. +mod api; +mod auth; +mod components; +mod dom; +mod model; +mod pages; + +use sycamore::prelude::*; +use sycamore_router::{HistoryIntegration, Route, Router}; + +#[derive(Route, Clone)] +enum AppRoutes { + #[to("/")] + Dashboard, + #[to("/login")] + Login, + #[to("/tokens")] + Tokens, + #[to("/vaults//certificates/new")] + CertificateNew { id: String }, + #[to("/vaults//certificates//renew")] + CertificateRenew { id: String, cert_id: String }, + #[to("/vaults//certificates/")] + CertificateDetail { id: String, cert_id: String }, + #[to("/vaults//certificates")] + Certificates { id: String }, + #[to("/vaults//requests/new")] + RequestNew { id: String }, + #[to("/vaults//requests/")] + RequestDetail { id: String, request_id: String }, + #[to("/vaults//requests")] + Requests { id: String }, + #[to("/vaults//keys/new")] + KeyNew { id: String }, + #[to("/vaults//keys/")] + KeyDetail { id: String, key_id: String }, + #[to("/vaults//keys")] + Keys { id: String }, + #[to("/vaults//crls/")] + CrlDetail { id: String, crl_id: String }, + #[to("/vaults//crls")] + Crls { id: String }, + #[to("/vaults//templates/new")] + TemplateNew { id: String }, + #[to("/vaults//templates/")] + TemplateDetail { id: String, template_id: String }, + #[to("/vaults//templates")] + Templates { id: String }, + #[to("/vaults//import")] + Import { id: String }, + #[to("/vaults//search")] + Search { id: String }, + #[to("/vaults//settings")] + Settings { id: String }, + #[to("/vaults/")] + VaultOverview { id: String }, + #[not_found] + NotFound, +} + +fn main() { + console_error_panic_hook::set_once(); + sycamore::render(|| { + view! { + Router( + integration=HistoryIntegration::new(), + view=|route: ReadSignal| { + view! { + div(class="app") { + (match route.get_clone() { + AppRoutes::Dashboard => view! { pages::dashboard::DashboardPage() }, + AppRoutes::Login => view! { pages::login::LoginPage() }, + AppRoutes::Tokens => view! { pages::tokens::TokensPage() }, + AppRoutes::CertificateNew { id } => view! { (pages::certificate_new::CertificateNewPage(id.clone())) }, + AppRoutes::CertificateRenew { id, cert_id } => view! { (pages::certificate_new::CertificateRenewPage(id.clone(), cert_id.clone())) }, + AppRoutes::CertificateDetail { id, cert_id } => view! { (pages::certificate_detail::CertificateDetailPage(id.clone(), cert_id.clone())) }, + AppRoutes::Certificates { id } => view! { (pages::certificates::CertificatesPage(id.clone())) }, + AppRoutes::RequestNew { id } => view! { (pages::request_new::RequestNewPage(id.clone())) }, + AppRoutes::RequestDetail { id, request_id } => view! { (pages::request_detail::RequestDetailPage(id.clone(), request_id.clone())) }, + AppRoutes::Requests { id } => view! { (pages::requests::RequestsPage(id.clone())) }, + AppRoutes::KeyNew { id } => view! { (pages::key_new::KeyNewPage(id.clone())) }, + AppRoutes::KeyDetail { id, key_id } => view! { (pages::key_detail::KeyDetailPage(id.clone(), key_id.clone())) }, + AppRoutes::Keys { id } => view! { (pages::keys::KeysPage(id.clone())) }, + AppRoutes::CrlDetail { id, crl_id } => view! { (pages::crl_detail::CrlDetailPage(id.clone(), crl_id.clone())) }, + AppRoutes::Crls { id } => view! { (pages::crls::CrlsPage(id.clone())) }, + AppRoutes::TemplateNew { id } => view! { (pages::template_editor::TemplateEditorPage(id.clone(), None)) }, + AppRoutes::TemplateDetail { id, template_id } => view! { (pages::template_editor::TemplateEditorPage(id.clone(), Some(template_id.clone()))) }, + AppRoutes::Templates { id } => view! { (pages::templates::TemplatesPage(id.clone())) }, + AppRoutes::Import { id } => view! { (pages::import::ImportPage(id.clone())) }, + AppRoutes::Search { id } => view! { (pages::search::SearchPage(id.clone())) }, + AppRoutes::Settings { id } => view! { (pages::settings::SettingsPage(id.clone())) }, + AppRoutes::VaultOverview { id } => view! { (pages::vault::VaultPage(id.clone())) }, + AppRoutes::NotFound => view! { pages::not_found::NotFoundPage() }, + }) + } + } + } + ) + } + }); + restore_page_after_login(); +} + +fn restore_page_after_login() { + let return_to = auth::saved_return_to(); + let Some(path) = return_to else { return }; + if path == "/login" { + auth::clear_return_to(); + return; + } + wasm_bindgen_futures::spawn_local(async move { + if let Ok(Some(_)) = api::me_optional().await { + auth::clear_return_to(); + sycamore_router::navigate(&path); + } + }); +} diff --git a/web/src/model.rs b/web/src/model.rs new file mode 100644 index 0000000..3903eca --- /dev/null +++ b/web/src/model.rs @@ -0,0 +1,738 @@ +//! JSON models mirroring the KCA API and small display helpers. + +use serde::Deserialize; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct Me { + pub sub: String, + #[serde(default)] + pub display_name: String, + #[serde(default)] + pub picture: Option, + #[serde(default)] + pub master_key_is_dev: bool, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub preferred_username: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize)] +pub struct AuthProvider { + pub name: String, + pub login_url: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct AuthProvidersPage { + #[serde(default)] + pub providers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct ApiToken { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub prefix: String, + #[serde(default)] + pub created_at: String, + #[serde(default)] + pub last_used_at: Option, + #[serde(default)] + pub expires_at: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct ApiTokensPage { + #[serde(default)] + pub results: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct CreatedApiToken { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub prefix: String, + #[serde(default)] + pub created_at: String, + #[serde(default)] + pub last_used_at: Option, + #[serde(default)] + pub expires_at: Option, + #[serde(default)] + pub token: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct VaultCounts { + #[serde(default)] + pub keys: i64, + #[serde(default)] + pub certificates: i64, + #[serde(default)] + pub requests: i64, + #[serde(default)] + pub crls: i64, + #[serde(default)] + pub templates: i64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct VaultSummary { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub counts: VaultCounts, + #[serde(default)] + pub expiring_soon: i64, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub updated_at: Option, + #[serde(default)] + pub version: i64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct VaultDetail { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub counts: VaultCounts, + #[serde(default)] + pub expiring_soon: i64, + #[serde(default)] + pub settings: Value, + #[serde(default)] + pub version: i64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct VaultsPage { + #[serde(default)] + pub results: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct KeySummary { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub comment: String, + #[serde(default)] + pub source: String, + #[serde(default)] + pub key_type: String, + #[serde(default)] + pub key_size: Option, + #[serde(default)] + pub curve: Option, + #[serde(default)] + pub has_private: bool, + #[serde(default)] + pub fingerprint_sha256: String, + #[serde(default)] + pub use_count: i64, + #[serde(default)] + pub version: i64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct KeyDetail { + #[serde(flatten)] + pub summary: KeySummary, + #[serde(default)] + pub public_pem: String, + #[serde(default)] + pub possible_hashes: Vec, + #[serde(default)] + pub can_export_private: bool, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct KeysPage { + #[serde(default)] + pub results: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct CertificateSummary { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub comment: String, + #[serde(default)] + pub source: String, + #[serde(default)] + pub subject: String, + #[serde(default)] + pub issuer_id: Option, + #[serde(default)] + pub issuer_name: String, + #[serde(default)] + pub serial_hex: String, + #[serde(default)] + pub not_before: Option, + #[serde(default)] + pub not_after: Option, + #[serde(default)] + pub is_ca: bool, + #[serde(default)] + pub path_length: Option, + #[serde(default)] + pub has_private_key: bool, + #[serde(default)] + pub revoked: bool, + #[serde(default)] + pub revoked_at: Option, + #[serde(default)] + pub fingerprint_sha256: String, + #[serde(default)] + pub key_id: Option, + #[serde(default)] + pub version: i64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct CertificateDetail { + #[serde(flatten)] + pub summary: CertificateSummary, + #[serde(default)] + pub subject_entries: Vec, + #[serde(default)] + pub issuer_entries: Vec, + #[serde(default)] + pub subject_rfc4514: String, + #[serde(default)] + pub issuer_rfc4514: String, + #[serde(default)] + pub subject_der_hex: String, + #[serde(default)] + pub issuer_der_hex: String, + #[serde(default)] + pub signature_algorithm: String, + #[serde(default)] + pub fingerprint_md5: String, + #[serde(default)] + pub fingerprint_sha1: String, + #[serde(default)] + pub public_key_der_hex: String, + #[serde(default)] + pub extensions: Vec, + #[serde(default)] + pub extension_spec: Value, + #[serde(default)] + pub openssl_config: String, + #[serde(default)] + pub validation_errors: Vec, + #[serde(default)] + pub purposes: Vec, + #[serde(default)] + pub chain: Vec, + #[serde(default)] + pub revocation: Option, + #[serde(default)] + pub key_name: Option, + #[serde(default)] + pub key_type: Option, + #[serde(default)] + pub key_size: Option, + #[serde(default)] + pub request_id: Option, + #[serde(default)] + pub request_name: Option, + #[serde(default)] + pub can_sign: bool, + #[serde(default)] + pub authority: Option, + #[serde(default)] + pub cert_spec: Value, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct DnEntry { + #[serde(default)] + pub oid: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct ExtensionRow { + #[serde(default)] + pub oid: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub critical: bool, + #[serde(default)] + pub value: String, + #[serde(default)] + pub der_hex: String, + #[serde(default)] + pub spec: Value, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct ValidationError { + #[serde(default)] + pub code: Option, + #[serde(default)] + pub message: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct ChainItem { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub subject: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct AuthorityInfo { + #[serde(default)] + pub crl_expire: Option, + #[serde(default)] + pub crl_number: Option, + #[serde(default)] + pub crl_days: Option, + #[serde(default)] + pub default_template_id: Option, + #[serde(default)] + pub has_tls_auth_key: bool, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct CertificatesPage { + #[serde(default)] + pub results: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct RequestSummary { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub comment: String, + #[serde(default)] + pub source: String, + #[serde(default)] + pub subject: String, + #[serde(default)] + pub signed: bool, + #[serde(default)] + pub signed_at: Option, + #[serde(default)] + pub key_id: Option, + #[serde(default)] + pub fingerprint_sha256: String, + #[serde(default)] + pub certificate_count: i64, + #[serde(default)] + pub version: i64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct RequestDetail { + #[serde(flatten)] + pub summary: RequestSummary, + #[serde(default)] + pub subject_entries: Vec, + #[serde(default)] + pub subject_rfc4514: String, + #[serde(default)] + pub signature_algorithm: String, + #[serde(default)] + pub signature_valid: bool, + #[serde(default)] + pub attributes: Vec, + #[serde(default)] + pub extensions: Vec, + #[serde(default)] + pub extension_spec: Value, + #[serde(default)] + pub openssl_config: String, + #[serde(default)] + pub csr_spec: Value, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct AttributeRow { + #[serde(default)] + pub oid: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub values: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct RequestsPage { + #[serde(default)] + pub results: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct CrlSummary { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub comment: String, + #[serde(default)] + pub source: String, + #[serde(default)] + pub issuer_id: Option, + #[serde(default)] + pub issuer_name: String, + #[serde(default)] + pub crl_number: Option, + #[serde(default)] + pub last_update: Option, + #[serde(default)] + pub next_update: Option, + #[serde(default)] + pub revoked_count: i64, + #[serde(default)] + pub signature_algorithm: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct CrlDetail { + #[serde(flatten)] + pub summary: CrlSummary, + #[serde(default)] + pub issuer_entries: Vec, + #[serde(default)] + pub issuer_rfc4514: String, + #[serde(default)] + pub version: String, + #[serde(default)] + pub extensions: Vec, + #[serde(default)] + pub revocations: Vec, + #[serde(default)] + pub crl_spec: Value, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct CrlRevocation { + #[serde(default)] + pub serial_hex: String, + #[serde(default)] + pub revoked_at: String, + #[serde(default)] + pub invalidity_date: Option, + #[serde(default)] + pub reason: Option, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct CrlsPage { + #[serde(default)] + pub results: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct TemplateSummary { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub comment: String, + #[serde(default)] + pub source: String, + #[serde(default)] + pub subject: Value, + #[serde(default)] + pub settings: Value, + #[serde(default)] + pub builtin: bool, + #[serde(default)] + pub version: i64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct TemplatesPage { + #[serde(default)] + pub results: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct Revocation { + pub id: String, + #[serde(default)] + pub ca_id: String, + #[serde(default)] + pub serial_hex: String, + #[serde(default)] + pub revoked_at: Option, + #[serde(default)] + pub invalidity_date: Option, + #[serde(default)] + pub reason: String, + #[serde(default)] + pub crl_number: i64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct RevocationsPage { + #[serde(default)] + pub results: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct SearchResult { + #[serde(default)] + pub r#type: String, + #[serde(default)] + pub item: Value, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct SearchPage { + #[serde(default)] + pub results: Vec, + #[serde(default)] + pub counts: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct ImportItem { + #[serde(default)] + pub index: i64, + #[serde(default)] + pub kind: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub summary: String, + #[serde(default)] + pub is_private: bool, + #[serde(default)] + pub duplicate: bool, + #[serde(default)] + pub warnings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct ImportPreview { + #[serde(default)] + pub dry_run: bool, + #[serde(default)] + pub items: Vec, + #[serde(default)] + pub count: i64, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct ImportResult { + #[serde(default)] + pub imported: Vec, + #[serde(default)] + pub skipped: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct ExportPayload { + #[serde(default)] + pub filename: String, + #[serde(default)] + pub content_type: String, + #[serde(default)] + pub content_base64: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct SummaryPage { + #[serde(default)] + pub summary: VaultSummary, + #[serde(default)] + pub expiring: Vec, + #[serde(default)] + pub crls_due: Vec, + #[serde(default)] + pub recent_certificates: Vec, + #[serde(default)] + pub recent_keys: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct OptionsPage { + #[serde(default)] + pub key_types: Vec, + #[serde(default)] + pub hash_algorithms: Vec, + #[serde(default)] + pub curves: Vec, + #[serde(default)] + pub dn_attributes: Vec, + #[serde(default)] + pub eku: Vec, + #[serde(default)] + pub key_usages: Vec, + #[serde(default)] + pub revocation_reasons: Vec, + #[serde(default)] + pub defaults: Value, + #[serde(default)] + pub names: Value, + #[serde(default)] + pub templates: Vec, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct DnAttribute { + #[serde(default)] + pub oid: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub long_name: String, +} + +#[derive(Debug, Clone, PartialEq, Deserialize, Default)] +pub struct NamedOption { + #[serde(default)] + pub name: String, + #[serde(default)] + pub label: String, +} + + +impl std::ops::Deref for KeyDetail { + type Target = KeySummary; + fn deref(&self) -> &Self::Target { &self.summary } +} + +impl std::ops::Deref for CertificateDetail { + type Target = CertificateSummary; + fn deref(&self) -> &Self::Target { &self.summary } +} + +impl std::ops::Deref for RequestDetail { + type Target = RequestSummary; + fn deref(&self) -> &Self::Target { &self.summary } +} + +impl std::ops::Deref for CrlDetail { + type Target = CrlSummary; + fn deref(&self) -> &Self::Target { &self.summary } +} + +// --------------------------------------------------------------------------- +// Display helpers +// --------------------------------------------------------------------------- + +pub fn fmt_date(value: &Option) -> String { + match value { + Some(text) if !text.is_empty() => { + // Keep the date and time to the minute; certificates are not + // sub-second artifacts and the full ISO string is noisy. + let cleaned = text.replace('T', " "); + let trimmed = cleaned.trim_end_matches('Z'); + let mut parts = trimmed.splitn(2, '.'); + let base = parts.next().unwrap_or(trimmed); + base.chars().take(19).collect() + } + _ => "—".to_string(), + } +} + +pub fn fmt_fp(value: &str) -> String { + if value.is_empty() { + "—".to_string() + } else if value.len() > 24 { + format!("{}…{}", &value[..16], &value[value.len() - 8..]) + } else { + value.to_string() + } +} + +pub fn status_badge(cert: &CertificateSummary) -> (&'static str, &'static str) { + if cert.revoked { + ("badge-danger", "revoked") + } else if let Some(after) = &cert.not_after { + if after < &now_iso() { + ("badge-danger", "expired") + } else { + ("badge-soft", "valid") + } + } else { + ("badge-soft", "valid") + } +} + +fn now_iso() -> String { + // The browser's clock is enough for a visual badge; the server remains + // authoritative for validation and revocation. + js_sys::Date::new_0().to_iso_string().as_string().unwrap_or_default() +} + +pub fn item_total(counts: &VaultCounts) -> i64 { + counts.keys + counts.certificates + counts.requests + counts.crls + counts.templates +} + +pub fn json_str(value: &Value, key: &str) -> String { + value.get(key).and_then(|v| v.as_str()).unwrap_or_default().to_string() +} + +pub fn json_bool(value: &Value, key: &str) -> bool { + value.get(key).and_then(|v| v.as_bool()).unwrap_or(false) +} + +pub fn json_array(value: &Value, key: &str) -> Vec { + value + .get(key) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn formats_fingerprints() { + assert_eq!(fmt_fp(""), "—"); + assert_eq!(fmt_fp("abcdefghijklmnopqrstuvwxyz"), "abcdefghijklmnop…stuvwxyz"); + } + + #[test] + fn formats_dates() { + assert_eq!(fmt_date(&None), "—"); + assert_eq!( + fmt_date(&Some("2026-10-23T01:49:02.123Z".to_string())), + "2026-10-23 01:49:02" + ); + } + + #[test] + fn reads_json_values() { + let value = json!({"name": "CA", "ca": true, "items": [1, 2]}); + assert_eq!(json_str(&value, "name"), "CA"); + assert!(json_bool(&value, "ca")); + assert_eq!(json_array(&value, "items").len(), 2); + } +} diff --git a/web/src/pages/certificate_detail.rs b/web/src/pages/certificate_detail.rs new file mode 100644 index 0000000..1e69ab0 --- /dev/null +++ b/web/src/pages/certificate_detail.rs @@ -0,0 +1,421 @@ +//! Certificate detail, validation and CA actions. +use serde_json::json; +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, spinner, whoami}; +use crate::components::ui::{copy_button, empty_state, page_header, toast_error}; +use crate::dom; +use crate::model::{fmt_date, CertificateDetail, Me, Revocation}; + +pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let cert_id_value: &'static str = Box::leak(cert_id.clone().into_boxed_str()); + let me = create_signal(None::); + let cert = create_signal(None::); + let error = create_signal(None::); + let loading = create_signal(true); + let export_format = create_signal("pem".to_string()); + let export_password = create_signal(String::new()); + let revocations = create_signal(Vec::::new()); + let subject_entries = create_signal(Vec::::new()); + let issuer_entries = create_signal(Vec::::new()); + let extensions = create_signal(Vec::::new()); + let validation_errors = create_signal(Vec::::new()); + let purposes = create_signal(Vec::::new()); + let chain = create_signal(Vec::::new()); + let rev_reason = create_signal("unspecified".to_string()); + let rev_serial = create_signal(String::new()); + let ca_days = create_signal("30".to_string()); + let crl_days = create_signal("30".to_string()); + let crl_hash = create_signal("sha256".to_string()); + let tls_auth_text = create_signal(String::new()); + let new_name = create_signal(String::new()); + let new_comment = create_signal(String::new()); + + let load = move || { + spawn_local(async move { + match api::certificate(id, cert_id_value).await { + Ok(value) => { + new_name.set(value.summary.name.clone()); + new_comment.set(value.summary.comment.clone()); + if let Some(days) = value.authority.as_ref().and_then(|a| a.crl_days) { + ca_days.set(days.to_string()); + crl_days.set(days.to_string()); + } + subject_entries.set(value.subject_entries.clone()); + issuer_entries.set(value.issuer_entries.clone()); + extensions.set(value.extensions.clone()); + validation_errors.set(value.validation_errors.clone()); + purposes.set(value.purposes.clone()); + chain.set(value.chain.clone()); + cert.set(Some(value)); + if let Ok(page) = api::revocations(id, cert_id_value).await { + revocations.set(page.results); + } + } + Err(message) => error.set(Some(message)), + } + }); + }; + + spawn_local(async move { + if let Ok(user) = api::me().await { + me.set(Some(user)); + } + loading.set(false); + }); + load(); + + let do_export = move |_| { + let mut payload = json!({"format": export_format.get_clone()}); + if !export_password.get_clone().is_empty() { + payload["password"] = json!(export_password.get_clone()); + } + spawn_local(async move { + match api::export_certificate(id, cert_id_value, &payload).await { + Ok(payload) => { + if let Err(message) = dom::download_base64(&payload.filename, &payload.content_type, &payload.content_base64) { + error.set(Some(message)); + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let revoke = move |_| { + let payload = json!({"reason": rev_reason.get_clone()}); + spawn_local(async move { + match api::revoke_certificate(id, cert_id_value, &payload).await { + Ok(_) => load(), + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let remove = move |_| { + if !dom::confirm("Delete this certificate?") { + return; + } + spawn_local(async move { + match api::delete_certificate(id, cert_id_value).await { + Ok(()) => sycamore_router::navigate(&format!("/vaults/{id}/certificates")), + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let save_meta = move |_| { + let value = cert.get_clone(); + let Some(value) = value else { return }; + let payload = json!({"name": new_name.get_clone(), "comment": new_comment.get_clone(), "version": value.summary.version}); + spawn_local(async move { + match api::update_certificate(id, cert_id_value, &payload).await { + Ok(updated) => cert.set(Some(updated)), + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let transform = move |action: &'static str| { + spawn_local(async move { + match api::transform_certificate(id, cert_id_value, action).await { + Ok(value) => { + if let Some(item_id) = value.get("id").and_then(|v| v.as_str()) { + let route = match action { + "open-key" => "keys", + "request" => "requests", + "template" => "templates", + _ => "certificates", + }; + sycamore_router::navigate(&format!("/vaults/{id}/{route}/{item_id}")); + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let generate_crl = move |_| { + let payload = json!({ + "days": crl_days.get_clone().parse::().unwrap_or(30), + "signature_hash": crl_hash.get_clone(), + "with_reason": true, + "include_aki": true, + }); + spawn_local(async move { + match api::generate_crl(id, cert_id_value, &payload).await { + Ok(crl) => sycamore_router::navigate(&format!("/vaults/{id}/crls/{}", crl.summary.id)), + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let add_revocation = move |_| { + let payload = json!({"reason": rev_reason.get_clone(), "serial_hex": rev_serial.get_clone()}); + spawn_local(async move { + match api::add_revocation(id, cert_id_value, &payload).await { + Ok(_) => { + if let Ok(page) = api::revocations(id, cert_id_value).await { + revocations.set(page.results); + } + rev_serial.set(String::new()); + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let delete_revocation = move |rev_id: String| { + spawn_local(async move { + match api::delete_revocation(id, cert_id_value, &rev_id).await { + Ok(()) => { + if let Ok(page) = api::revocations(id, cert_id_value).await { + revocations.set(page.results); + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let make_tls_auth = move |_| { + spawn_local(async move { + match api::tls_auth(id, cert_id_value, &json!({})).await { + Ok(value) => { + if let Some(key) = value.get("key").and_then(|v| v.as_str()) { + tls_auth_text.set(key.to_string()); + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let content = move || { + if loading.get() { + return spinner(); + } + let Some(value) = cert.get_clone() else { + return empty_state("Certificate not found."); + }; + let summary = value.summary.clone(); + let is_ca = summary.is_ca; + let header_serial = summary.serial_hex.clone(); + view! { + (page_header( + summary.name.clone(), + format!("{} · serial {}", if is_ca { "CA certificate" } else { "end-entity certificate" }, header_serial), + view! { + a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Back" } + a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/{cert_id_value}/renew")) { "Renew" } + (if !summary.revoked && !is_ca { + view! { button(class="btn btn-warning", on:click=revoke) { "Revoke" } } + } else { view! {} }) + button(class="btn btn-outline-danger", on:click=remove) { "Delete" } + }, + )) + div(class="card mb-3") { + div(class="card-body") { + div(class="row g-2") { + div(class="col-md-6") { div(class="small text-secondary") { "Subject" } div(class="mono") { (value.subject_rfc4514.clone()) } } + div(class="col-md-6") { div(class="small text-secondary") { "Issuer" } div(class="mono") { (value.issuer_rfc4514.clone()) } } + div(class="col-md-3") { div(class="small text-secondary") { "Serial" } div(class="mono") { (summary.serial_hex.clone()) } } + div(class="col-md-3") { div(class="small text-secondary") { "Signature" } div() { (value.signature_algorithm.clone()) } } + div(class="col-md-3") { div(class="small text-secondary") { "Not before" } div() { (fmt_date(&summary.not_before)) } } + div(class="col-md-3") { div(class="small text-secondary") { "Not after" } div() { (fmt_date(&summary.not_after)) } } + div(class="col-md-3") { div(class="small text-secondary") { "Key" } div() { (value.key_name.clone().unwrap_or_else(|| "not in vault".to_string())) } } + div(class="col-md-3") { div(class="small text-secondary") { "Status" } (if summary.revoked { view! { span(class="badge badge-danger") { "revoked" } } } else { view! { span(class="badge badge-soft") { "valid" } } }) } + } + } + } + div(class="row g-3 mb-3") { + div(class="col-12 col-lg-6") { + div(class="card") { + div(class="card-header") { "Subject" } + table(class="table table-sm dn-table mb-0") { + tbody() { Indexed(list=subject_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) } + } + } + } + div(class="col-12 col-lg-6") { + div(class="card") { + div(class="card-header") { "Issuer" } + table(class="table table-sm dn-table mb-0") { + tbody() { Indexed(list=issuer_entries, view=|entry| view! { tr() { td() { (entry.name) } td() { (entry.value) } } }) } + } + } + } + } + div(class="card mb-3") { + div(class="card-header d-flex justify-content-between align-items-center") { + span() { "Extensions" } + (copy_button(value.openssl_config.clone(), "Copy OpenSSL config")) + } + div(class="table-responsive") { + table(class="table table-sm ext-table mb-0") { + thead() { tr() { th() { "Extension" } th() { "Value" } } } + tbody() { Indexed(list=extensions, view=|ext| view! { tr() { td() { (ext.name) (if ext.critical { view! { span(class="badge badge-critical ms-2") { "critical" } } } else { view! {} }) } td(class="extension-value") { (ext.value) } } }) } + } + } + } + div(class="card mb-3") { + div(class="card-header") { "Validation" } + div(class="card-body") { + (if validation_errors.get_clone().is_empty() { + view! { div(class="alert alert-success mb-2") { "No verification errors found." } } + } else { + view! { ul(class="text-danger") { Indexed(list=validation_errors, view=|err| view! { li() { (err.code.map(|c| format!("[{c}] ")).unwrap_or_default()) (err.message) } }) } } + }) + div(class="mt-2") { Indexed(list=purposes, view=|p| view! { span(class="badge badge-soft me-1") { (p) } }) } + } + } + div(class="row g-3") { + div(class="col-12 col-lg-6") { + div(class="card mb-3") { + div(class="card-header") { "Export" } + div(class="card-body") { + select(class="form-select mb-2", bind:value=export_format) { + option(value="pem") { "PEM" } + option(value="pem-chain") { "PEM chain" } + option(value="pem-key") { "PEM + key" } + option(value="pem-pkcs8") { "PEM + encrypted PKCS#8" } + option(value="pkcs7") { "PKCS#7" } + option(value="pkcs7-chain") { "PKCS#7 chain" } + option(value="der") { "DER" } + option(value="pkcs12") { "PKCS#12" } + option(value="pkcs12-chain") { "PKCS#12 chain" } + option(value="openvpn") { "OpenVPN" } + option(value="openssl-config") { "OpenSSL config" } + option(value="index") { "Certificate index" } + option(value="vcalendar") { "vCalendar" } + option(value="jwk") { "JWK" } + option(value="jwk-chain") { "JWK chain" } + option(value="tls-auth") { "OpenVPN tls-auth key" } + } + div(class="mb-2") { + label(class="form-label") { "Password (encrypted formats)" } + input(class="form-control", r#type="password", bind:value=export_password) + } + button(class="btn btn-primary", on:click=do_export) { "Download" } + } + } + div(class="card") { + div(class="card-header") { "Metadata" } + div(class="card-body") { + div(class="mb-2") { + label(class="form-label") { "Name" } + input(class="form-control", bind:value=new_name) + } + div(class="mb-2") { + label(class="form-label") { "Comment" } + textarea(class="form-control", rows="3", bind:value=new_comment) {} + } + button(class="btn btn-primary", on:click=save_meta) { "Save" } + } + } + } + div(class="col-12 col-lg-6") { + div(class="card mb-3") { + div(class="card-header") { "Revoke" } + div(class="card-body") { + select(class="form-select mb-2", bind:value=rev_reason) { (reason_options()) } + button(class="btn btn-warning", disabled=summary.revoked, on:click=revoke) { "Revoke certificate" } + } + } + div(class="card") { + div(class="card-header") { "Transform" } + div(class="card-body d-grid gap-2") { + button(class="btn btn-outline-light", on:click=move |_| transform("open-key")) { "Extract public key" } + button(class="btn btn-outline-light", on:click=move |_| transform("request")) { "Create request" } + button(class="btn btn-outline-light", on:click=move |_| transform("template")) { "Create template" } + } + } + } + } + (if is_ca { + view! { + div(class="card mt-3") { + div(class="card-header") { "CA operations" } + div(class="card-body") { + div(class="row g-2 align-items-end mb-3") { + div(class="col-md-3") { + label(class="form-label") { "CRL days" } + input(class="form-control", bind:value=crl_days) + } + div(class="col-md-3") { + label(class="form-label") { "Hash" } + select(class="form-select", bind:value=crl_hash) { + option(value="sha256") { "SHA-256" } + option(value="sha384") { "SHA-384" } + option(value="sha512") { "SHA-512" } + } + } + div(class="col-md-3") { + button(class="btn btn-primary", on:click=generate_crl) { "Generate CRL" } + } + div(class="col-md-3") { + button(class="btn btn-outline-light", on:click=make_tls_auth) { "tls-auth key" } + } + } + (if tls_auth_text.get_clone().is_empty() { + view! {} + } else { + view! { pre(class="pem") { (tls_auth_text.get_clone()) } } + }) + h3(class="h6 mt-3") { "Revocations" } + div(class="row g-2 align-items-end mb-2") { + div(class="col-md-4") { + input(class="form-control", placeholder="serial (hex)", bind:value=rev_serial) + } + div(class="col-md-4") { + select(class="form-select", bind:value=rev_reason) { (reason_options()) } + } + div(class="col-md-4") { + button(class="btn btn-outline-light", on:click=add_revocation) { "Add revocation" } + } + } + table(class="table table-sm mb-0") { + thead() { tr() { th() { "Serial" } th() { "Revoked" } th() { "Reason" } th() {} } } + tbody() { Indexed(list=revocations, view=move |rev| { let rev_id = rev.id.clone(); view! { tr() { td(class="fingerprint") { (rev.serial_hex) } td() { (fmt_date(&rev.revoked_at)) } td() { (rev.reason) } td() { button(class="btn btn-sm btn-outline-danger", on:click=move |_| delete_revocation(rev_id.clone())) { "Delete" } } } } }) } + } + } + } + } + } else { + view! {} + }) + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (content) + } + } +} + +fn reason_options() -> Vec { + ["unspecified", "keyCompromise", "CACompromise", "affiliationChanged", "superseded", "cessationOfOperation", "certificateHold", "privilegeWithdrawn"] + .iter() + .map(|reason| view! { option(value=*reason) { (*reason) } }) + .collect::>() +} diff --git a/web/src/pages/certificate_new.rs b/web/src/pages/certificate_new.rs new file mode 100644 index 0000000..5a52f63 --- /dev/null +++ b/web/src/pages/certificate_new.rs @@ -0,0 +1,727 @@ +//! Certificate creation wizard and renewal form. +use serde_json::json; +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, whoami}; +use crate::components::ui::{page_header, toast_error}; +use crate::model::{CertificateDetail, CertificateSummary, KeySummary, Me, TemplateSummary}; + +pub fn CertificateNewPage(vault_id: String) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let me = create_signal(None::); + let keys = create_signal(Vec::::new()); + let signers = create_signal(Vec::::new()); + let templates = create_signal(Vec::::new()); + let error = create_signal(None::); + let saving = create_signal(false); + + let request_id = query_param("request").unwrap_or_default(); + let request_mode = !request_id.is_empty(); + let request_id_for_load = request_id.clone(); + + let name = create_signal(String::new()); + let comment = create_signal(String::new()); + let signer_id = create_signal("self".to_string()); + let key_id = create_signal(String::new()); + let new_key = create_signal(false); + let key_type = create_signal("rsa".to_string()); + let key_size = create_signal("2048".to_string()); + let template_id = create_signal(String::new()); + let signature_hash = create_signal("sha256".to_string()); + + let cn = create_signal(String::new()); + let country = create_signal(String::new()); + let state = create_signal(String::new()); + let locality = create_signal(String::new()); + let organization = create_signal(String::new()); + let org_unit = create_signal(String::new()); + let email = create_signal(String::new()); + + let ca_mode = create_signal("end".to_string()); + let path_length = create_signal(String::new()); + let bc_critical = create_signal(true); + let ku_digital = create_signal(true); + let ku_encipher = create_signal(false); + let ku_key_agreement = create_signal(false); + let ku_cert_sign = create_signal(false); + let ku_crl_sign = create_signal(false); + let ku_critical = create_signal(true); + let eku_server = create_signal(false); + let eku_client = create_signal(false); + let eku_code = create_signal(false); + let eku_email = create_signal(false); + let eku_time = create_signal(false); + let eku_ocsp = create_signal(false); + let eku_critical = create_signal(false); + let san_text = create_signal(String::new()); + let ski = create_signal(true); + let aki = create_signal(true); + let ocsp_staple = create_signal(false); + + let validity_amount = create_signal("365".to_string()); + let validity_unit = create_signal("days".to_string()); + let midnight = create_signal(false); + + + spawn_local(async move { + if let Ok(user) = api::me().await { + if me.is_alive() { + me.set(Some(user)); + } + } + if let Ok(page) = api::keys(&id).await { + keys.set(page.results); + } + if let Ok(page) = api::certificates(&id).await { + signers.set(page.results.into_iter().filter(|cert| cert.is_ca && cert.has_private_key).collect()); + } + if let Ok(page) = api::templates(&id).await { + templates.set(page.results); + } + if request_mode { + if let Ok(value) = api::request(&id, &request_id_for_load).await { + name.set(format!("{} certificate", value.summary.name)); + if let Some(first) = value.subject_entries.iter().find(|e| e.name == "CN") { + cn.set(first.value.clone()); + } + if let Some(first) = value.subject_entries.iter().find(|e| e.name == "O") { + organization.set(first.value.clone()); + } + if let Some(first) = value.subject_entries.iter().find(|e| e.name == "OU") { + org_unit.set(first.value.clone()); + } + if let Some(first) = value.subject_entries.iter().find(|e| e.name == "C") { + country.set(first.value.clone()); + } + if let Some(first) = value.subject_entries.iter().find(|e| e.name == "ST") { + state.set(first.value.clone()); + } + if let Some(first) = value.subject_entries.iter().find(|e| e.name == "L") { + locality.set(first.value.clone()); + } + if let Some(first) = value.subject_entries.iter().find(|e| e.name == "emailAddress") { + email.set(first.value.clone()); + } + } + } + }); + + let apply_template = { + let id = id.clone(); + move |_| { + let template = template_id.get_clone(); + if template.is_empty() { + return; + } + let id = id.clone(); + spawn_local(async move { + match api::template(&id, &template).await { + Ok(template) => { + if let Some(settings) = template.settings.get("extensions") { + if let Some(bc) = settings.get("basic_constraints") { + if bc.get("ca").and_then(|v| v.as_bool()).unwrap_or(false) { + ca_mode.set("ca".to_string()); + if let Some(len) = bc.get("path_length").and_then(|v| v.as_i64()) { + path_length.set(len.to_string()); + } + } else if bc.get("ca").is_some() { + ca_mode.set("end".to_string()); + } + bc_critical.set(bc.get("critical").and_then(|v| v.as_bool()).unwrap_or(true)); + } + if let Some(ku) = settings.get("key_usage") { + let usages = string_array(ku.get("usages")); + ku_digital.set(usages.iter().any(|s| s == "digital_signature")); + ku_encipher.set(usages.iter().any(|s| s == "key_encipherment")); + ku_key_agreement.set(usages.iter().any(|s| s == "key_agreement")); + ku_cert_sign.set(usages.iter().any(|s| s == "key_cert_sign")); + ku_crl_sign.set(usages.iter().any(|s| s == "crl_sign")); + ku_critical.set(ku.get("critical").and_then(|v| v.as_bool()).unwrap_or(true)); + } + if let Some(eku) = settings.get("extended_key_usage") { + let usages = string_array(eku.get("usages")); + eku_server.set(usages.iter().any(|s| s == "serverAuth")); + eku_client.set(usages.iter().any(|s| s == "clientAuth")); + eku_code.set(usages.iter().any(|s| s == "codeSigning")); + eku_email.set(usages.iter().any(|s| s == "emailProtection")); + eku_time.set(usages.iter().any(|s| s == "timeStamping")); + eku_ocsp.set(usages.iter().any(|s| s == "OCSPSigning")); + eku_critical.set(eku.get("critical").and_then(|v| v.as_bool()).unwrap_or(false)); + } + if let Some(san) = settings.get("subject_alt_name") { + let entries = san.get("entries").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let text = entries.iter().filter_map(|entry| { + let kind = entry.get("type").and_then(|v| v.as_str()).unwrap_or("dns"); + let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or(""); + if value.is_empty() { None } else { Some(format!("{}:{}", kind.to_uppercase(), value)) } + }).collect::>().join(", "); + san_text.set(text); + } + ski.set(settings.get("subject_key_identifier").is_some()); + aki.set(settings.get("authority_key_identifier").is_some()); + } + if let Some(subject) = template.subject.as_array() { + for entry in subject { + let key = entry.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or("").to_string(); + match key { + "CN" => cn.set(value), + "O" => organization.set(value), + "OU" => org_unit.set(value), + "C" => country.set(value), + "ST" => state.set(value), + "L" => locality.set(value), + "emailAddress" => email.set(value), + _ => {} + } + } + } + if let Some(validity) = template.settings.get("validity") { + if let Some(amount) = validity.get("amount").and_then(|v| v.as_i64()) { + validity_amount.set(amount.to_string()); + } + if let Some(unit) = validity.get("unit").and_then(|v| v.as_str()) { + validity_unit.set(unit.to_string()); + } + midnight.set(validity.get("midnight").and_then(|v| v.as_bool()).unwrap_or(false)); + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + } + }; + + let submit = { + let id = id.clone(); + let request_id = request_id.clone(); + move |_| { + let cert_name = name.get_clone(); + if cert_name.trim().is_empty() { + error.set(Some("certificate name is required".to_string())); + return; + } + let subject = build_subject(&[ + ("C", country.get_clone()), + ("ST", state.get_clone()), + ("L", locality.get_clone()), + ("O", organization.get_clone()), + ("OU", org_unit.get_clone()), + ("CN", cn.get_clone()), + ("emailAddress", email.get_clone()), + ]); + if subject.is_empty() { + error.set(Some("the subject must not be empty".to_string())); + return; + } + let mut extensions = json!({}); + let mode = ca_mode.get_clone(); + let crit = bc_critical.get(); + if mode == "ca" { + extensions["basic_constraints"] = json!({ + "critical": crit, + "ca": true, + "path_length": path_length.get_clone().parse::().ok(), + }); + } else if mode == "end" { + extensions["basic_constraints"] = json!({"critical": crit, "ca": false, "path_length": null}); + } + let usages = [ + (ku_digital.get(), "digital_signature"), + (ku_encipher.get(), "key_encipherment"), + (ku_key_agreement.get(), "key_agreement"), + (ku_cert_sign.get(), "key_cert_sign"), + (ku_crl_sign.get(), "crl_sign"), + ] + .iter() + .filter(|(checked, _)| *checked) + .map(|(_, name)| json!(name)) + .collect::>(); + extensions["key_usage"] = json!({"critical": ku_critical.get(), "usages": usages}); + let eku = [ + (eku_server.get(), "serverAuth"), + (eku_client.get(), "clientAuth"), + (eku_code.get(), "codeSigning"), + (eku_email.get(), "emailProtection"), + (eku_time.get(), "timeStamping"), + (eku_ocsp.get(), "OCSPSigning"), + ] + .iter() + .filter(|(checked, _)| *checked) + .map(|(_, name)| json!(name)) + .collect::>(); + extensions["extended_key_usage"] = json!({"critical": eku_critical.get(), "usages": eku}); + let san = parse_san(&san_text.get_clone()); + if !san.is_empty() { + extensions["subject_alt_name"] = json!({"critical": false, "entries": san}); + } + if ski.get() { + extensions["subject_key_identifier"] = json!({"critical": false}); + } + if aki.get() { + extensions["authority_key_identifier"] = json!({"critical": false}); + } + if ocsp_staple.get() { + extensions["tls_feature"] = json!({"critical": false, "features": ["status_request"]}); + } + let signer = signer_id.get_clone(); + let mut payload = json!({ + "name": cert_name, + "comment": comment.get_clone(), + "subject": subject, + "extensions": extensions, + "signature_hash": signature_hash.get_clone(), + "signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) }, + "validity": { + "amount": validity_amount.get_clone().parse::().unwrap_or(365), + "unit": validity_unit.get_clone(), + "midnight": midnight.get(), + }, + }); + if !request_id.is_empty() { + payload["request_id"] = json!(request_id); + payload["modify_subject"] = json!(true); + payload["copy_request_extensions"] = json!(false); + } else if new_key.get() { + payload["new_key"] = json!({ + "key_type": key_type.get_clone(), + "size": key_size.get_clone().parse::().unwrap_or(2048), + "name": format!("{} key", name.get_clone()), + }); + } else if !key_id.get_clone().is_empty() { + payload["key_id"] = json!(key_id.get_clone()); + } else { + error.set(Some("select a key or generate a new one".to_string())); + return; + } + saving.set(true); + let id = id.clone(); + spawn_local(async move { + match api::create_certificate(&id, &payload).await { + Ok(cert) => { + saving.set(false); + sycamore_router::navigate(&format!("/vaults/{}/certificates/{}", id, cert.summary.id)); + return; + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + if saving.is_alive() { saving.set(false); } + }); + } + }; + + let signer_options = move || { + signers.get_clone().into_iter().map(|cert| view! { + option(value=cert.id.clone()) { (cert.name.clone()) } + }).collect::>() + }; + let key_options = move || { + keys.get_clone().into_iter().filter(|key| key.has_private).map(|key| view! { + option(value=key.id.clone()) { (key.name.clone()) } + }).collect::>() + }; + let template_options = move || { + templates.get_clone().into_iter().map(|template| view! { + option(value=template.id.clone()) { (if template.builtin { format!("[built-in] {}", template.name) } else { template.name.clone() }) } + }).collect::>() + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (page_header( + if request_mode { "Sign certificate request" } else { "New certificate" }, + "Certificates can be self-signed or issued by a CA with a private key in this vault.", + view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Cancel" } }, + )) + (if request_mode { + view! { div(class="alert alert-info") { "The subject and public key come from the selected request; the fields below override the subject." } } + } else { + view! {} + }) + div(class="card") { + div(class="card-body") { + div(class="form-section") { + div(class="form-section-title") { "Source" } + div(class="row g-3") { + div(class="col-md-6") { + label(class="form-label") { "Issuer" } + select(class="form-select", bind:value=signer_id) { + option(value="self") { "Self-signed" } + (signer_options()) + } + } + div(class="col-md-6") { + label(class="form-label") { "Signature hash" } + select(class="form-select", bind:value=signature_hash) { + option(value="sha256") { "SHA-256" } + option(value="sha384") { "SHA-384" } + option(value="sha512") { "SHA-512" } + option(value="sha1") { "SHA-1" } + } + } + div(class="col-md-6") { + label(class="form-label") { "Template" } + div(class="input-group") { + select(class="form-select", bind:value=template_id) { + option(value="") { "— no template —" } + (template_options()) + } + button(class="btn btn-outline-light", on:click=apply_template) { "Apply" } + } + } + div(class="col-md-6") { + label(class="form-label") { "Internal name" } + input(class="form-control", bind:value=name) + } + div(class="col-12") { + label(class="form-label") { "Comment" } + textarea(class="form-control", rows="2", bind:value=comment) {} + } + } + } + div(class="form-section") { + div(class="form-section-title") { "Subject" } + div(class="row g-3") { + (subject_input("Common name (CN)", cn)) + (subject_input("Organization (O)", organization)) + (subject_input("Organizational unit (OU)", org_unit)) + (subject_input("Country (C)", country)) + (subject_input("State / province (ST)", state)) + (subject_input("Locality (L)", locality)) + (subject_input("E-mail address", email)) + } + } + (if request_mode { + view! {} + } else { + view! { + div(class="form-section") { + div(class="form-section-title") { "Private key" } + div(class="form-check mb-2") { + input(class="form-check-input", r#type="checkbox", id="new-key", bind:checked=new_key) + label(class="form-check-label", r#for="new-key") { "Generate a new key" } + } + (if new_key.get() { + view! { + div(class="row g-3") { + div(class="col-md-4") { + label(class="form-label") { "Key type" } + select(class="form-select", bind:value=key_type) { + option(value="rsa") { "RSA" } + option(value="ec") { "EC" } + option(value="ed25519") { "ED25519" } + } + } + div(class="col-md-4") { + label(class="form-label") { "Key size" } + input(class="form-control", bind:value=key_size) + } + } + } + } else { + view! { + label(class="form-label") { "Existing key" } + select(class="form-select", bind:value=key_id) { + option(value="") { "— select a key —" } + (key_options()) + } + } + }) + } + } + }) + div(class="form-section") { + div(class="form-section-title") { "Extensions" } + div(class="row g-3" ) { + div(class="col-md-4") { + label(class="form-label") { "Basic constraints" } + select(class="form-select", bind:value=ca_mode) { + option(value="end") { "End entity (CA:FALSE)" } + option(value="ca") { "Certification authority (CA:TRUE)" } + option(value="none") { "Not defined" } + } + } + div(class="col-md-3") { + label(class="form-label") { "Path length" } + input(class="form-control", placeholder="unlimited", bind:value=path_length) + } + div(class="col-md-3 d-flex align-items-end") { + div(class="form-check") { + input(class="form-check-input", r#type="checkbox", bind:checked=bc_critical) + label(class="form-check-label") { "Critical" } + } + } + div(class="col-md-2 d-flex align-items-end") { + div(class="form-check") { + input(class="form-check-input", r#type="checkbox", bind:checked=ski) + label(class="form-check-label") { "SKI" } + } + } + } + div(class="row g-3 mt-1") { + div(class="col-md-6") { + div(class="form-section-title") { "Key usage" } + (check("Digital signature", ku_digital)) + (check("Key encipherment", ku_encipher)) + (check("Key agreement", ku_key_agreement)) + (check("Certificate sign", ku_cert_sign)) + (check("CRL sign", ku_crl_sign)) + (check("Critical", ku_critical)) + } + div(class="col-md-6") { + div(class="form-section-title") { "Extended key usage" } + (check("TLS server", eku_server)) + (check("TLS client", eku_client)) + (check("Code signing", eku_code)) + (check("E-mail protection", eku_email)) + (check("Time stamping", eku_time)) + (check("OCSP signing", eku_ocsp)) + (check("Critical", eku_critical)) + } + div(class="col-md-6") { + div(class="form-section-title") { "Subject alternative names" } + input(class="form-control", placeholder="DNS:example.com, IP:192.0.2.1", bind:value=san_text) + } + div(class="col-md-6") { + div(class="form-section-title") { "Key identifier / OCSP" } + (check("Authority key identifier", aki)) + (check("OCSP stapling", ocsp_staple)) + } + } + } + div(class="form-section") { + div(class="form-section-title") { "Validity" } + div(class="row g-3") { + div(class="col-md-3") { + label(class="form-label") { "Duration" } + input(class="form-control", bind:value=validity_amount) + } + div(class="col-md-3") { + label(class="form-label") { "Unit" } + select(class="form-select", bind:value=validity_unit) { + option(value="days") { "Days" } + option(value="months") { "Months" } + option(value="years") { "Years" } + } + } + div(class="col-md-3 d-flex align-items-end") { + div(class="form-check") { + input(class="form-check-input", r#type="checkbox", bind:checked=midnight) + label(class="form-check-label") { "Round to midnight" } + } + } + } + } + button(class="btn btn-primary btn-lg", disabled=saving.get(), on:click=submit) { + (if saving.get() { "Issuing…" } else if request_mode { "Issue certificate" } else { "Create certificate" }) + } + } + } + } + } +} + +pub fn CertificateRenewPage(vault_id: String, cert_id: String) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let cert_id_value: &'static str = Box::leak(cert_id.clone().into_boxed_str()); + let me = create_signal(None::); + let cert = create_signal(None::); + let error = create_signal(None::); + let loading = create_signal(true); + let saving = create_signal(false); + let name = create_signal(String::new()); + let amount = create_signal("365".to_string()); + let unit = create_signal("days".to_string()); + let keep_serial = create_signal(false); + let revoke_old = create_signal(false); + let replace_old = create_signal(false); + + spawn_local(async move { + match api::me().await { + Ok(user) => { + me.set(Some(user)); + match api::certificate(&id, &cert_id_value).await { + Ok(value) => { + name.set(value.summary.name.clone()); + cert.set(Some(value)); + } + Err(message) => error.set(Some(message)), + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + loading.set(false); + }); + + let submit = { + let id = id.clone(); + let cert_id_value = cert_id_value.clone(); + move |_| { + let id = id.clone(); + let cert_id_value = cert_id_value.clone(); + let payload = json!({ + "name": name.get_clone(), + "validity": {"amount": amount.get_clone().parse::().unwrap_or(365), "unit": unit.get_clone()}, + "keep_serial": keep_serial.get(), + "revoke_old": revoke_old.get(), + "replace_old": replace_old.get(), + }); + saving.set(true); + spawn_local(async move { + match api::renew_certificate(&id, &cert_id_value, &payload).await { + Ok(value) => { + saving.set(false); + sycamore_router::navigate(&format!("/vaults/{}/certificates/{}", id, value.summary.id)); + return; + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + if saving.is_alive() { saving.set(false); } + }); + } + }; + + let content = move || { + if loading.get() { + return crate::components::ui::spinner(); + } + let Some(value) = cert.get_clone() else { + return crate::components::ui::empty_state("Certificate not found."); + }; + view! { + (page_header("Renew certificate", &format!("Issued by {}", value.issuer_name), view! { + a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates/{cert_id_value}")) { "Cancel" } + })) + div(class="card") { + div(class="card-body") { + div(class="row g-3") { + div(class="col-md-8") { + label(class="form-label") { "New internal name" } + input(class="form-control", bind:value=name) + } + div(class="col-md-2") { + label(class="form-label") { "Duration" } + input(class="form-control", bind:value=amount) + } + div(class="col-md-2") { + label(class="form-label") { "Unit" } + select(class="form-select", bind:value=unit) { + option(value="days") { "Days" } + option(value="months") { "Months" } + option(value="years") { "Years" } + } + } + } + div(class="form-check mt-3") { + input(class="form-check-input", r#type="checkbox", id="keep-serial", bind:checked=keep_serial) + label(class="form-check-label", r#for="keep-serial") { "Keep serial number" } + } + div(class="form-check") { + input(class="form-check-input", r#type="checkbox", id="revoke-old", bind:checked=revoke_old) + label(class="form-check-label", r#for="revoke-old") { "Revoke old certificate" } + } + div(class="form-check mb-3") { + input(class="form-check-input", r#type="checkbox", id="replace-old", bind:checked=replace_old) + label(class="form-check-label", r#for="replace-old") { "Replace old certificate" } + } + button(class="btn btn-primary", disabled=saving.get(), on:click=submit) { + (if saving.get() { "Renewing…" } else { "Renew certificate" }) + } + } + } + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (content) + } + } +} + +fn subject_input(label: &'static str, value: Signal) -> View { + view! { + div(class="col-md-6") { + label(class="form-label") { (label) } + input(class="form-control", bind:value=value) + } + } +} + +fn check(label: &'static str, value: Signal) -> View { + view! { + div(class="form-check") { + input(class="form-check-input", r#type="checkbox", bind:checked=value) + label(class="form-check-label") { (label) } + } + } +} + +fn build_subject(fields: &[(&str, String)]) -> Vec { + fields + .iter() + .filter(|(_, value)| !value.trim().is_empty()) + .map(|(name, value)| json!({"name": name, "value": value.trim()})) + .collect() +} + +fn parse_san(text: &str) -> Vec { + text.split(',') + .map(|chunk| chunk.trim()) + .filter(|chunk| !chunk.is_empty()) + .map(|chunk| { + if let Some((prefix, value)) = chunk.split_once(':') { + let kind = match prefix.trim().to_lowercase().as_str() { + "ip" => "ip", + "uri" => "uri", + "email" => "email", + "rid" => "rid", + "dns" => "dns", + _ => "dns", + }; + json!({"type": kind, "value": value.trim()}) + } else { + json!({"type": "dns", "value": chunk}) + } + }) + .collect() +} + +fn string_array(value: Option<&serde_json::Value>) -> Vec { + value + .and_then(|v| v.as_array()) + .map(|items| items.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default() +} + +fn query_param(name: &str) -> Option { + let search = web_sys::window()?.location().search().ok()?; + let search = search.trim_start_matches('?'); + for pair in search.split('&') { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + if key == name { + return Some(value.to_string()); + } + } + None +} diff --git a/web/src/pages/certificates.rs b/web/src/pages/certificates.rs new file mode 100644 index 0000000..07dda64 --- /dev/null +++ b/web/src/pages/certificates.rs @@ -0,0 +1,204 @@ +//! Certificate list, flat or as an issuer tree. +use std::collections::HashMap; + +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, spinner, whoami}; +use crate::components::ui::{page_header, toast_error}; +use crate::model::{fmt_date, fmt_fp, status_badge, CertificateSummary, Me}; + +pub fn CertificatesPage(vault_id: String) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let me = create_signal(None::); + let certificates = create_signal(Vec::::new()); + let error = create_signal(None::); + let loading = create_signal(true); + let filter = create_signal(String::new()); + let plain = create_signal(false); + + spawn_local(async move { + match api::me().await { + Ok(user) => { + if me.is_alive() { + me.set(Some(user)); + } + match api::certificates(id).await { + Ok(page) => { + if certificates.is_alive() { + certificates.set(page.results); + } + } + Err(message) if message != api::LOGIN_REQUIRED => { + if error.is_alive() { + error.set(Some(message)); + } + } + _ => {} + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => { + if error.is_alive() { + error.set(Some(message)); + } + } + } + if loading.is_alive() { + loading.set(false); + } + }); + + let content = move || { + if loading.get() { + return spinner(); + } + let rows = certificates.get_clone(); + let query = filter.get_clone().to_lowercase(); + let visible: Vec = rows + .into_iter() + .filter(|cert| { + query.is_empty() + || cert.name.to_lowercase().contains(&query) + || cert.subject.to_lowercase().contains(&query) + || cert.issuer_name.to_lowercase().contains(&query) + || cert.serial_hex.to_lowercase().contains(&query) + || cert.fingerprint_sha256.to_lowercase().contains(&query) + }) + .collect(); + let html = if plain.get() { + flat_html(&visible, id) + } else { + tree_html(&visible, id) + }; + view! { + (page_header( + "Certificates", + "The tree follows issuer links; use the plain view for a flat table.", + view! { + a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/new")) { "New certificate" } + a(class="btn btn-outline-light", href=format!("/vaults/{id}/import")) { "Import" } + button(class="btn btn-outline-light", on:click=move |_| plain.set(!plain.get())) { + (if plain.get() { "Tree view" } else { "Plain view" }) + } + }, + )) + div(class="card mb-3") { + div(class="card-body py-2") { + input(class="form-control", placeholder="Filter by name, subject, issuer, serial or fingerprint…", + bind:value=filter) + } + } + div(class="card", dangerously_set_inner_html=html) {} + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (content) + } + } +} + +fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String { + if rows.is_empty() { + return "

No certificates match.

".to_string(); + } + let mut body = String::new(); + for cert in rows { + let (badge_class, badge_label) = status_badge(cert); + body.push_str(&format!( + "{}
{}
{}{}{}{}{}{}{}", + escape(vault_id), + escape(&cert.id), + escape(&cert.name), + escape(&cert.subject), + escape(&cert.issuer_name), + if cert.is_ca { "CA" } else { "end entity" }, + escape(&cert.serial_hex), + fmt_date(&cert.not_after), + badge_class, + badge_label, + if cert.has_private_key { " key" } else { "" }, + fmt_fp(&cert.fingerprint_sha256), + )); + } + format!( + "
{body}
NameIssuerTypeSerialExpiresStatusSHA-256
" + ) +} + +fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> String { + if rows.is_empty() { + return "

No certificates match.

".to_string(); + } + let mut children: HashMap> = HashMap::new(); + let mut roots: Vec = Vec::new(); + let ids: Vec = rows.iter().map(|cert| cert.id.clone()).collect(); + for cert in rows { + match &cert.issuer_id { + Some(issuer) if ids.contains(issuer) && issuer != &cert.id => { + children.entry(issuer.clone()).or_default().push(cert.clone()); + } + _ => roots.push(cert.clone()), + } + } + roots.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + let mut html = String::from("
    "); + for root in &roots { + html.push_str(&node_html(root, &children, vault_id)); + } + html.push_str("
"); + html +} + +fn node_html( + cert: &CertificateSummary, + children: &HashMap>, + vault_id: &str, +) -> String { + let (badge_class, badge_label) = status_badge(cert); + let sub = if cert.subject == cert.issuer_name { + "self-signed".to_string() + } else { + format!("issuer: {}", cert.issuer_name) + }; + let mut row = format!( + "
{}{} {}{}expires {}{}
", + escape(vault_id), + escape(&cert.id), + escape(&cert.name), + if cert.is_ca { " CA" } else { "" }, + badge_class, + badge_label, + escape(&sub), + fmt_date(&cert.not_after), + fmt_fp(&cert.fingerprint_sha256), + ); + let kids = children.get(&cert.id); + if let Some(kids) = kids { + if !kids.is_empty() { + row = format!( + "
  • {row}
      {}
  • ", + kids.iter().map(|child| node_html(child, children, vault_id)).collect::() + ); + } else { + row = format!("
  • {row}
  • "); + } + } else { + row = format!("
  • {row}
  • "); + } + row +} + +fn escape(value: &str) -> String { + value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} diff --git a/web/src/pages/crl_detail.rs b/web/src/pages/crl_detail.rs new file mode 100644 index 0000000..7d5ecab --- /dev/null +++ b/web/src/pages/crl_detail.rs @@ -0,0 +1,162 @@ +//! CRL detail. +use serde_json::json; +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, spinner, whoami}; +use crate::components::ui::{empty_state, page_header, toast_error}; +use crate::dom; +use crate::model::{fmt_date, CrlDetail, CrlRevocation, ExtensionRow, Me}; + +pub fn CrlDetailPage(vault_id: String, crl_id: String) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let crl_id_value: &'static str = Box::leak(crl_id.clone().into_boxed_str()); + let me = create_signal(None::); + let crl = create_signal(None::); + let revocations = create_signal(Vec::::new()); + let extensions = create_signal(Vec::::new()); + let error = create_signal(None::); + let loading = create_signal(true); + let format = create_signal("pem".to_string()); + + spawn_local(async move { + if let Ok(user) = api::me().await { + me.set(Some(user)); + } + match api::crl(id, crl_id_value).await { + Ok(value) => { + revocations.set(value.revocations.clone()); + extensions.set(value.extensions.clone()); + crl.set(Some(value)); + } + Err(message) => error.set(Some(message)), + } + loading.set(false); + }); + + let export = move |_| { + let fmt = format.get_clone(); + spawn_local(async move { + match api::export_crl(id, crl_id_value, &json!({"format": fmt})).await { + Ok(payload) => { + if let Err(message) = dom::download_base64(&payload.filename, &payload.content_type, &payload.content_base64) { + error.set(Some(message)); + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let remove = move |_| { + if !dom::confirm("Delete this revocation list?") { + return; + } + spawn_local(async move { + match api::delete_crl(id, crl_id_value).await { + Ok(()) => sycamore_router::navigate(&format!("/vaults/{id}/crls")), + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let content = move || { + if loading.get() { + return spinner(); + } + let Some(value) = crl.get_clone() else { + return empty_state("CRL not found."); + }; + let summary = value.summary.clone(); + let name = summary.name.clone(); + let number = summary.crl_number.map(|n| n.to_string()).unwrap_or_else(|| "—".to_string()); + let issuer = summary.issuer_name.clone(); + let last = fmt_date(&summary.last_update); + let next = fmt_date(&summary.next_update); + let sig = summary.signature_algorithm.clone(); + let revoked_count = summary.revoked_count; + view! { + (page_header(name.clone(), format!("CRL number {number} · {revoked_count} revoked"), view! { + a(class="btn btn-outline-light", href=format!("/vaults/{id}/crls")) { "Back" } + button(class="btn btn-outline-danger", on:click=remove) { "Delete" } + })) + div(class="row g-3") { + div(class="col-12 col-lg-4") { + div(class="card mb-3") { + div(class="card-header") { "Properties" } + div(class="card-body") { + div(class="py-1") { div(class="small text-secondary") { "Issuer" } div(class="mono") { (issuer) } } + div(class="py-1") { div(class="small text-secondary") { "Last update" } div() { (last) } } + div(class="py-1") { div(class="small text-secondary") { "Next update" } div() { (next) } } + div(class="py-1") { div(class="small text-secondary") { "Signature" } div() { (sig) } } + div(class="py-1") { div(class="small text-secondary") { "Revoked" } div() { (revoked_count) } } + } + } + div(class="card") { + div(class="card-header") { "Export" } + div(class="card-body") { + select(class="form-select mb-2", bind:value=format) { + option(value="pem") { "PEM" } + option(value="der") { "DER" } + option(value="vcalendar") { "vCalendar" } + } + button(class="btn btn-primary", on:click=export) { "Download" } + } + } + } + div(class="col-12 col-lg-8") { + div(class="card mb-3") { + div(class="card-header") { "Revoked certificates" } + div(class="table-responsive") { + table(class="table table-sm mb-0") { + thead() { tr() { th() { "Serial" } th() { "Revoked" } th() { "Reason" } th() { "Invalid since" } } } + tbody() { + Indexed(list=revocations, view=|rev| view! { + tr() { + td(class="fingerprint") { (rev.serial_hex.clone()) } + td() { (fmt_date(&Some(rev.revoked_at.clone()))) } + td() { (rev.reason.clone().unwrap_or_else(|| "unspecified".to_string())) } + td() { (rev.invalidity_date.clone().map(|v| fmt_date(&Some(v))).unwrap_or_else(|| "—".to_string())) } + } + }) + } + } + } + } + div(class="card") { + div(class="card-header") { "Extensions" } + div(class="table-responsive") { + table(class="table table-sm ext-table mb-0") { + thead() { tr() { th() { "Extension" } th() { "Value" } } } + tbody() { + Indexed(list=extensions, view=|ext| view! { + tr() { + td() { (ext.name.clone()) (if ext.critical { view! { span(class="badge badge-critical ms-2") { "critical" } } } else { view! {} }) } + td(class="extension-value") { (ext.value.clone()) } + } + }) + } + } + } + } + } + } + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (content) + } + } +} diff --git a/web/src/pages/crls.rs b/web/src/pages/crls.rs new file mode 100644 index 0000000..c8aa41a --- /dev/null +++ b/web/src/pages/crls.rs @@ -0,0 +1,107 @@ +//! Revocation list overview. +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, spinner, whoami}; +use crate::components::ui::{empty_state, page_header, toast_error}; +use crate::model::{fmt_date, CrlSummary, Me}; + +pub fn CrlsPage(vault_id: String) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let me = create_signal(None::); + let crls = create_signal(Vec::::new()); + let error = create_signal(None::); + let loading = create_signal(true); + + spawn_local(async move { + match api::me().await { + Ok(user) => { + if me.is_alive() { + me.set(Some(user)); + } + match api::crls(&id).await { + Ok(page) => { + if crls.is_alive() { + crls.set(page.results); + } + } + Err(message) if message != api::LOGIN_REQUIRED => { + if error.is_alive() { + error.set(Some(message)); + } + } + _ => {} + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => { + if error.is_alive() { + error.set(Some(message)); + } + } + } + if loading.is_alive() { + loading.set(false); + } + }); + + let content = move || { + if loading.get() { + return spinner(); + } + let is_empty = crls.get_clone().is_empty(); + let table = if is_empty { + view! { div(class="card") { div(class="card-body") { (empty_state("No CRLs yet. Generate one from a CA certificate.")) } } } + } else { + let html = crls.get_clone().into_iter().map(|crl| { + format!( + "{}
    {}
    {}{}{}{}", + id, + escape(&crl.id), + escape(&crl.name), + escape(&crl.issuer_name), + crl.crl_number.map(|n| n.to_string()).unwrap_or_else(|| "—".to_string()), + crl.revoked_count, + fmt_date(&crl.last_update), + fmt_date(&crl.next_update), + ) + }).collect::(); + view! { + div(class="card") { + div(class="table-responsive") { + table(class="table table-hover align-middle mb-0") { + thead() { tr() { th() { "Name" } th() { "Number" } th() { "Revoked" } th() { "Last update" } th() { "Next update" } } } + tbody(dangerously_set_inner_html=html) {} + } + } + } + } + }; + view! { + (page_header( + "Revocation lists", + "CRLs are generated from a CA certificate's revocations; importing a CRL merges its serials.", + view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates")) { "Choose a CA" } }, + )) + (table) + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (content) + } + } +} + +fn escape(value: &str) -> String { + value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} diff --git a/web/src/pages/dashboard.rs b/web/src/pages/dashboard.rs new file mode 100644 index 0000000..108ee31 --- /dev/null +++ b/web/src/pages/dashboard.rs @@ -0,0 +1,206 @@ +//! Vault list and creation. +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, spinner, whoami}; +use crate::components::ui::{empty_state, page_header, toast_error}; +use crate::dom; +use crate::model::{item_total, Me, VaultSummary}; + +#[component] +pub fn DashboardPage() -> View { + let me = create_signal(None::); + let vaults = create_signal(Vec::::new()); + let error = create_signal(None::); + let loading = create_signal(true); + let show_form = create_signal(false); + let name = create_signal(String::new()); + let description = create_signal(String::new()); + let saving = create_signal(false); + + let reload = move || { + spawn_local(async move { + match api::vaults().await { + Ok(page) => { + if vaults.is_alive() { + vaults.set(page.results); + } + } + Err(message) if message != api::LOGIN_REQUIRED => { + if error.is_alive() { + error.set(Some(message)); + } + } + _ => {} + } + }); + }; + + spawn_local(async move { + match api::me().await { + Ok(user) => { + if me.is_alive() { + me.set(Some(user)); + } + reload(); + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => { + if error.is_alive() { + error.set(Some(message)); + } + } + } + if loading.is_alive() { + loading.set(false); + } + }); + + let create = move |_| { + let vault_name = name.get_clone(); + let vault_description = description.get_clone(); + if vault_name.trim().is_empty() { + error.set(Some("vault name is required".to_string())); + return; + } + saving.set(true); + spawn_local(async move { + match api::create_vault(&vault_name, &vault_description).await { + Ok(_) => { + name.set(String::new()); + description.set(String::new()); + show_form.set(false); + reload(); + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + saving.set(false); + }); + }; + + let content = move || { + if loading.get() { + return spinner(); + } + let rows = vaults.get_clone(); + let is_empty = rows.is_empty(); + let cards: Vec = rows + .into_iter() + .map(|vault| { + let id = vault.id.clone(); + let name = vault.name.clone(); + let description = vault.description.clone(); + let total = item_total(&vault.counts); + let expiring = vault.expiring_soon; + let counts = vault.counts.clone(); + let delete_id = vault.id.clone(); + let delete_name = vault.name.clone(); + let remove = move |_| { + if !dom::confirm(&format!("Delete the vault '{}' and everything in it?", delete_name)) { + return; + } + let id = delete_id.clone(); + spawn_local(async move { + match api::delete_vault(&id).await { + Ok(()) => reload(), + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + view! { + div(class="col-12 col-md-6 col-xl-4") { + div(class="card vault-card") { + div(class="card-body d-flex flex-column") { + div(class="d-flex justify-content-between align-items-start gap-2") { + div() { + a(class="h5 stretched-link text-decoration-none", href=format!("/vaults/{id}")) { (name) } + div(class="item-sub") { (description) } + } + button(class="btn btn-sm btn-outline-danger position-relative", + style="z-index: 2;", + on:click=remove) { "Delete" } + } + div(class="row g-2 mt-3") { + div(class="col-4") { div(class="stat-number") { (counts.certificates) } div(class="stat-label") { "Certs" } } + div(class="col-4") { div(class="stat-number") { (counts.keys) } div(class="stat-label") { "Keys" } } + div(class="col-4") { div(class="stat-number") { (total) } div(class="stat-label") { "Items" } } + } + (if expiring > 0 { + view! { div(class="badge badge-warn mt-3") { (expiring) " expiring within 30 days" } } + } else { + view! { div(class="text-secondary small mt-3") { "No certificates expiring soon" } } + }) + } + } + } + } + }) + .collect(); + let empty = if is_empty { + view! { div(class="card") { div(class="card-body") { (empty_state("No vaults yet. Create one to start managing keys and certificates.")) } } } + } else { + view! {} + }; + let form = if show_form.get() { + view! { + div(class="card mb-4") { + div(class="card-body") { + h2(class="h5 mb-3") { "New vault" } + div(class="mb-2") { + label(class="form-label") { "Name" } + input(class="form-control", placeholder="Production PKI", bind:value=name) + } + div(class="mb-3") { + label(class="form-label") { "Description" } + textarea(class="form-control", rows="2", bind:value=description) {} + } + button(class="btn btn-primary", disabled=saving.get(), on:click=create) { + (if saving.get() { "Creating…" } else { "Create vault" }) + } + button(class="btn btn-outline-light ms-2", on:click=move |_| show_form.set(false)) { "Cancel" } + } + } + } + } else { + view! {} + }; + view! { + (page_header( + "Vaults", + "Each vault is a private XCA-style database of keys, certificates, requests, CRLs and templates.", + view! { + button(class="btn btn-primary", on:click=move |_| show_form.set(true)) { "New vault" } + a(class="btn btn-outline-light", href="/tokens") { "API tokens" } + }, + )) + (form) + (empty) + div(class="row g-3") { (cards) } + (if me.get_clone().map(|u| u.master_key_is_dev).unwrap_or(false) { + view! { + div(class="alert alert-info mt-4 mb-0") { + "This server uses the built-in development master key. Set KCA_MASTER_KEY before storing real private keys." + } + } + } else { + view! {} + }) + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", None, nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (content) + } + } +} diff --git a/web/src/pages/import.rs b/web/src/pages/import.rs new file mode 100644 index 0000000..4c87068 --- /dev/null +++ b/web/src/pages/import.rs @@ -0,0 +1,207 @@ +//! Import files, PEM bundles and XCA databases. +use serde_json::json; +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, spinner, whoami}; +use crate::components::ui::{page_header, toast_error}; +use crate::dom; +use crate::model::{ImportItem, Me}; + +pub fn ImportPage(vault_id: String) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let me = create_signal(None::); + let error = create_signal(None::); + let info = create_signal(None::); + let loading = create_signal(true); + let busy = create_signal(false); + let filename = create_signal(String::new()); + let content_base64 = create_signal(String::new()); + let pem_text = create_signal(String::new()); + let password = create_signal(String::new()); + let xca_db = create_signal(false); + let preview_text = create_signal(String::new()); + let result_text = create_signal(String::new()); + + spawn_local(async move { + if let Ok(user) = api::me().await { + me.set(Some(user)); + } + loading.set(false); + }); + + let choose_file = move |event: web_sys::Event| { + let Some(file) = dom::input_file(&event) else { return }; + let name = file.name(); + xca_db.set(name.to_lowercase().ends_with(".xdb")); + filename.set(name); + preview_text.set(String::new()); + result_text.set(String::new()); + content_base64.set(String::new()); + spawn_local(async move { + match dom::read_file_base64(&file).await { + Ok(value) => content_base64.set(value), + Err(message) => error.set(Some(message)), + } + }); + }; + + let build_payload = move |dry_run: bool| { + let mut payload = json!({ + "filename": if filename.get_clone().is_empty() { "paste.pem".to_string() } else { filename.get_clone() }, + "password": if password.get_clone().is_empty() { serde_json::Value::Null } else { json!(password.get_clone()) }, + "dry_run": dry_run, + "xca_db": xca_db.get(), + }); + if !pem_text.get_clone().trim().is_empty() { + payload["pem"] = json!(pem_text.get_clone()); + } else if !content_base64.get_clone().is_empty() { + payload["content_base64"] = json!(content_base64.get_clone()); + } + payload + }; + + let dry_run = move |_| { + let payload = build_payload(true); + if payload.get("pem").is_none() && payload.get("content_base64").is_none() { + error.set(Some("choose a file or paste PEM text".to_string())); + return; + } + busy.set(true); + preview_text.set(String::new()); + result_text.set(String::new()); + spawn_local(async move { + match api::import_items(id, &payload).await { + Ok(value) => preview_text.set(preview_lines(&value.items)), + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + busy.set(false); + }); + }; + + let commit = move |_| { + let payload = build_payload(false); + if payload.get("pem").is_none() && payload.get("content_base64").is_none() { + error.set(Some("choose a file or paste PEM text".to_string())); + return; + } + busy.set(true); + result_text.set(String::new()); + spawn_local(async move { + match api::import_commit(id, &payload).await { + Ok(value) => { + info.set(Some(format!("Imported {} item(s), skipped {}.", value.imported.len(), value.skipped.len()))); + result_text.set(import_result_lines(&value.imported, &value.skipped)); + preview_text.set(String::new()); + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + busy.set(false); + }); + }; + + let content = move || { + if loading.get() { + return spinner(); + } + view! { + (page_header( + "Import", + "PEM bundles, DER items, PKCS#7, PKCS#12, SSH keys, XCA templates and XCA .xdb databases.", + view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}")) { "Back" } }, + )) + (if let Some(message) = info.get_clone() { + view! { div(class="alert alert-success") { (message) } } + } else { + view! {} + }) + div(class="row g-3") { + div(class="col-12 col-lg-5") { + div(class="card mb-3") { + div(class="card-header") { "File" } + div(class="card-body") { + input(class="form-control mb-2", r#type="file", on:change=choose_file) + (if filename.get_clone().is_empty() { + view! {} + } else { + view! { div(class="text-secondary small") { (filename.get_clone()) } } + }) + div(class="form-check mt-2") { + input(class="form-check-input", r#type="checkbox", id="xca-db", bind:checked=xca_db) + label(class="form-check-label", r#for="xca-db") { "This is an XCA .xdb database" } + } + div(class="mt-2") { + label(class="form-label") { "Password (encrypted keys / PKCS#12 / XCA database)" } + input(class="form-control", r#type="password", bind:value=password) + } + } + } + div(class="card mb-3") { + div(class="card-header") { "Paste PEM" } + div(class="card-body") { + textarea(class="form-control", rows="8", placeholder="-----BEGIN CERTIFICATE-----", bind:value=pem_text) {} + } + } + div(class="d-flex gap-2") { + button(class="btn btn-outline-light", disabled=busy.get(), on:click=dry_run) { "Preview" } + button(class="btn btn-primary", disabled=busy.get(), on:click=commit) { "Import" } + } + } + div(class="col-12 col-lg-7") { + div(class="card mb-3") { + div(class="card-header") { "Preview" } + div(class="card-body") { + pre(class="pem mb-0") { (preview_text.get_clone()) } + } + } + div(class="card") { + div(class="card-header") { "Result" } + div(class="card-body") { + pre(class="pem mb-0") { (result_text.get_clone()) } + } + } + } + } + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (content) + } + } +} + +fn preview_lines(items: &[ImportItem]) -> String { + items.iter().map(|item| { + format!( + "[{}] {} {}{}{}", + item.index, + item.kind, + item.name, + if item.is_private { " (private)" } else { "" }, + if item.duplicate { " [duplicate]" } else { "" }, + ) + }).collect::>().join("\n") +} + +fn import_result_lines(imported: &[ImportItem], skipped: &[ImportItem]) -> String { + let mut lines = Vec::new(); + for item in imported { + lines.push(format!("imported {} {}", item.kind, item.name)); + } + for item in skipped { + lines.push(format!("skipped {} {} ({})", item.kind, item.name, item.summary)); + } + lines.join("\n") +} diff --git a/web/src/pages/key_detail.rs b/web/src/pages/key_detail.rs new file mode 100644 index 0000000..bf09963 --- /dev/null +++ b/web/src/pages/key_detail.rs @@ -0,0 +1,208 @@ +//! Key detail, rename, export and delete. +use serde_json::json; +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, spinner, whoami}; +use crate::components::ui::{copy_button, page_header, toast_error}; +use crate::dom; +use crate::model::{fmt_fp, KeyDetail, Me}; + +const KEY_FORMATS: &[(&str, &str, bool)] = &[ + ("pem-public", "PEM public", false), + ("pem-private", "PEM private", true), + ("pem-encrypted", "PEM encrypted", true), + ("pkcs8", "PKCS#8", true), + ("pkcs8-encrypted", "PKCS#8 encrypted", true), + ("der-public", "DER public", false), + ("der-private", "DER private", true), + ("ssh-public", "SSH2 public", false), + ("ssh-private", "SSH2 private", true), + ("jwk-public", "JWK public", false), + ("jwk-private", "JWK private", true), + ("pvk", "PVK private", true), +]; + +pub fn KeyDetailPage(vault_id: String, key_id: String) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let key_id_value: &'static str = Box::leak(key_id.clone().into_boxed_str()); + let me = create_signal(None::); + let key = create_signal(None::); + let error = create_signal(None::); + let loading = create_signal(true); + let name = create_signal(String::new()); + let comment = create_signal(String::new()); + let format = create_signal("pem-public".to_string()); + let password = create_signal(String::new()); + let header_sub = create_signal(String::new()); + let public_pem = create_signal(String::new()); + let name_value = create_signal(String::new()); + + spawn_local(async move { + if let Ok(user) = api::me().await { + me.set(Some(user)); + } + match api::key(id, key_id_value).await { + Ok(value) => { + name.set(value.summary.name.clone()); + comment.set(value.summary.comment.clone()); + name_value.set(value.summary.name.clone()); + header_sub.set(format!("{}{}", value.summary.key_type, value.summary.key_size.map(|s| format!(" · {s} bits")).unwrap_or_default())); + public_pem.set(value.public_pem.clone()); + key.set(Some(value)); + } + Err(message) => error.set(Some(message)), + } + loading.set(false); + }); + + let save = move |_| { + let value = key.get_clone(); + let Some(value) = value else { return }; + let payload = json!({"name": name.get_clone(), "comment": comment.get_clone(), "version": value.summary.version}); + spawn_local(async move { + match api::update_key(id, key_id_value, &payload).await { + Ok(updated) => { + name.set(updated.summary.name.clone()); + comment.set(updated.summary.comment.clone()); + key.set(Some(updated)); + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let export = move |_| { + let mut payload = json!({"format": format.get_clone()}); + if !password.get_clone().is_empty() { + payload["password"] = json!(password.get_clone()); + } + spawn_local(async move { + match api::export_key(id, key_id_value, &payload).await { + Ok(export) => { + if let Err(message) = dom::download_base64(&export.filename, &export.content_type, &export.content_base64) { + error.set(Some(message)); + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let remove = move |_| { + if !dom::confirm("Delete this key? Certificates and requests referencing it keep their data but lose the key link.") { + return; + } + spawn_local(async move { + match api::delete_key(id, key_id_value).await { + Ok(()) => sycamore_router::navigate(&format!("/vaults/{id}/keys")), + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let content = move || { + if loading.get() { + return spinner(); + } + let Some(value) = key.get_clone() else { + return crate::components::ui::empty_state("Key not found."); + }; + let summary = value.summary.clone(); + let key_type = summary.key_type.clone(); + let key_size = summary.key_size.map(|s| format!("{s} bits")).unwrap_or_else(|| "—".to_string()); + let curve = summary.curve.clone().unwrap_or_else(|| "—".to_string()); + let protection = if summary.has_private { "private key, encrypted at rest" } else { "public only" }; + let use_count = summary.use_count; + let fingerprint = fmt_fp(&summary.fingerprint_sha256); + let source = summary.source.clone(); + let has_private = summary.has_private; + let formats = KEY_FORMATS.iter().map(|(value, label, needs_private)| { + view! { option(value=*value, disabled=*needs_private && !has_private) { (*label) } } + }).collect::>(); + view! { + (page_header(name_value.get_clone(), header_sub.get_clone(), view! { + a(class="btn btn-outline-light", href=format!("/vaults/{id}/keys")) { "Back" } + button(class="btn btn-outline-danger", on:click=remove) { "Delete" } + })) + div(class="row g-3") { + div(class="col-12 col-lg-7") { + div(class="card mb-3") { + div(class="card-header") { "Properties" } + div(class="card-body") { + table(class="table table-sm mb-0") { + tbody() { + tr() { td(class="text-secondary") { "Type" } td() { (key_type) } } + tr() { td(class="text-secondary") { "Size" } td() { (key_size) } } + tr() { td(class="text-secondary") { "Curve" } td() { (curve) } } + tr() { td(class="text-secondary") { "Protection" } td() { (protection) } } + tr() { td(class="text-secondary") { "Used by" } td() { (use_count) " certificate(s)/request(s)" } } + tr() { td(class="text-secondary") { "SHA-256" } td(class="fingerprint") { (fingerprint) } } + tr() { td(class="text-secondary") { "Source" } td() { (source) } } + } + } + } + } + div(class="card") { + div(class="card-header") { "Public key" } + div(class="card-body") { + (copy_button(public_pem.get_clone(), "Copy PEM")) + pre(class="pem mt-2") { (public_pem.get_clone()) } + } + } + } + div(class="col-12 col-lg-5") { + div(class="card mb-3") { + div(class="card-header") { "Edit metadata" } + div(class="card-body") { + div(class="mb-2") { + label(class="form-label") { "Name" } + input(class="form-control", bind:value=name) + } + div(class="mb-2") { + label(class="form-label") { "Comment" } + textarea(class="form-control", rows="3", bind:value=comment) {} + } + button(class="btn btn-primary", on:click=save) { "Save" } + } + } + div(class="card") { + div(class="card-header") { "Export" } + div(class="card-body") { + div(class="mb-2") { + label(class="form-label") { "Format" } + select(class="form-select", bind:value=format) { (formats) } + } + div(class="mb-3") { + label(class="form-label") { "Password (encrypted formats)" } + input(class="form-control", r#type="password", bind:value=password) + } + button(class="btn btn-primary", on:click=export) { "Download" } + } + } + } + } + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (content) + } + } +} + +fn value_private(summary: &crate::model::KeySummary) -> bool { + summary.has_private +} diff --git a/web/src/pages/key_new.rs b/web/src/pages/key_new.rs new file mode 100644 index 0000000..2c827b9 --- /dev/null +++ b/web/src/pages/key_new.rs @@ -0,0 +1,152 @@ +//! Generate a key. +use serde_json::json; +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, whoami}; +use crate::components::ui::{page_header, toast_error}; +use crate::model::Me; + +const RSA_SIZES: &[&str] = &["1024", "2048", "3072", "4096", "8192"]; +const DSA_SIZES: &[&str] = &["1024", "2048", "3072"]; +const CURVES: &[&str] = &[ + "secp256r1", + "secp384r1", + "secp521r1", + "secp256k1", + "brainpoolP256r1", + "brainpoolP384r1", + "brainpoolP512r1", +]; + +pub fn KeyNewPage(vault_id: String) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let me = create_signal(None::); + let error = create_signal(None::); + let name = create_signal(String::new()); + let comment = create_signal(String::new()); + let key_type = create_signal("rsa".to_string()); + let size = create_signal("2048".to_string()); + let curve = create_signal("secp256r1".to_string()); + let saving = create_signal(false); + + spawn_local(async move { + if let Ok(user) = api::me().await { + if me.is_alive() { + me.set(Some(user)); + } + } + }); + + let submit = move |_| { + let key_name = name.get_clone(); + if key_name.trim().is_empty() { + error.set(Some("key name is required".to_string())); + return; + } + let key_type_value = key_type.get_clone(); + let mut payload = json!({ + "name": key_name, + "comment": comment.get_clone(), + "key_type": key_type_value, + }); + if key_type_value == "ec" { + payload["curve"] = json!(curve.get_clone()); + } else if key_type_value == "rsa" || key_type_value == "dsa" { + payload["size"] = json!(size.get_clone().parse::().unwrap_or(2048)); + } + saving.set(true); + let id = id.clone(); + spawn_local(async move { + match api::generate_key(&id, &payload).await { + Ok(key) => { + saving.set(false); + sycamore_router::navigate(&format!("/vaults/{}/keys/{}", id, key.summary.id)); + return; + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + if saving.is_alive() { + saving.set(false); + } + }); + }; + + let type_options = move || { + ["rsa", "dsa", "ec", "ed25519"] + .iter() + .map(|value| { + view! { + option(value=*value) { + (if *value == "ed25519" { "ED25519".to_string() } else { value.to_uppercase() }) + } + } + }) + .collect::>() + }; + + let parameter_input = move || { + let kind = key_type.get_clone(); + if kind == "ec" { + view! { + label(class="form-label") { "Curve" } + select(class="form-select", bind:value=curve) { + (CURVES.iter().map(|value| view! { + option(value=*value) { (*value) } + }).collect::>()) + } + } + } else if kind == "ed25519" { + view! { div(class="alert alert-info py-2 mb-0") { "Ed25519 keys have a fixed 256-bit size." } } + } else { + let sizes = if kind == "dsa" { DSA_SIZES } else { RSA_SIZES }; + view! { + label(class="form-label") { "Key size (bits)" } + select(class="form-select", bind:value=size) { + (sizes.iter().map(|value| view! { + option(value=*value) { (*value) } + }).collect::>()) + } + } + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (page_header("New key", "Generate a key and store it encrypted in this vault.", view! { + a(class="btn btn-outline-light", href=format!("/vaults/{id}/keys")) { "Cancel" } + })) + div(class="card") { + div(class="card-body") { + div(class="mb-3") { + label(class="form-label") { "Internal name" } + input(class="form-control", placeholder="webserver-key", bind:value=name) + } + div(class="row g-3 mb-3") { + div(class="col-md-4") { + label(class="form-label") { "Key type" } + select(class="form-select", bind:value=key_type) { (type_options()) } + } + div(class="col-md-4") { (move || parameter_input()) } + } + div(class="mb-3") { + label(class="form-label") { "Comment" } + textarea(class="form-control", rows="2", bind:value=comment) {} + } + button(class="btn btn-primary", disabled=saving.get(), on:click=submit) { + (if saving.get() { "Generating…" } else { "Generate key" }) + } + } + } + } + } +} diff --git a/web/src/pages/keys.rs b/web/src/pages/keys.rs new file mode 100644 index 0000000..5283d8e --- /dev/null +++ b/web/src/pages/keys.rs @@ -0,0 +1,119 @@ +//! Key list. +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, spinner, whoami}; +use crate::components::ui::{empty_state, page_header, toast_error}; +use crate::model::{fmt_fp, KeySummary, Me}; + +pub fn KeysPage(vault_id: String) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let me = create_signal(None::); + let keys = create_signal(Vec::::new()); + let error = create_signal(None::); + let loading = create_signal(true); + + let reload = move || { + let id = id.clone(); + spawn_local(async move { + match api::keys(&id).await { + Ok(page) => { + if keys.is_alive() { + keys.set(page.results); + } + } + Err(message) if message != api::LOGIN_REQUIRED => { + if error.is_alive() { + error.set(Some(message)); + } + } + _ => {} + } + }); + }; + + spawn_local(async move { + match api::me().await { + Ok(user) => { + if me.is_alive() { + me.set(Some(user)); + } + reload(); + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => { + if error.is_alive() { + error.set(Some(message)); + } + } + } + if loading.is_alive() { + loading.set(false); + } + }); + + let content = move || { + if loading.get() { + return spinner(); + } + let is_empty = keys.get_clone().is_empty(); + let html = keys.get_clone().into_iter().map(|key| { + format!( + "{}
    {}
    {}{}{}{}{}{}", + id, + escape(&key.id), + escape(&key.name), + escape(&key.comment), + escape(&key.key_type), + key.key_size.map(|v| v.to_string()).unwrap_or_else(|| "—".to_string()), + escape(key.curve.as_deref().unwrap_or("—")), + if key.has_private { "private" } else { "public" }, + key.use_count, + fmt_fp(&key.fingerprint_sha256), + ) + }).collect::(); + let table = if is_empty { + view! { div(class="card") { div(class="card-body") { (empty_state("No keys yet. Generate one or import a PEM/DER key.")) } } } + } else { + view! { + div(class="card") { + div(class="table-responsive") { + table(class="table table-hover align-middle mb-0") { + thead() { tr() { th() { "Name" } th() { "Type" } th() { "Size" } th() { "Curve" } th() { "Protection" } th() { "Use" } th() { "SHA-256" } } } + tbody(dangerously_set_inner_html=html) {} + } + } + } + } + }; + view! { + (page_header( + "Private keys", + "Keys are encrypted with the server master key. Private material never leaves the server except through an explicit export.", + view! { + a(class="btn btn-primary", href=format!("/vaults/{id}/keys/new")) { "New key" } + a(class="btn btn-outline-light", href=format!("/vaults/{id}/import")) { "Import" } + }, + )) + (table) + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (content) + } + } +} + +fn escape(value: &str) -> String { + value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} diff --git a/web/src/pages/login.rs b/web/src/pages/login.rs new file mode 100644 index 0000000..2c06ec8 --- /dev/null +++ b/web/src/pages/login.rs @@ -0,0 +1,72 @@ +//! OIDC provider choice. +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::app_nav; +use crate::components::ui::{spinner, toast_error}; +use crate::model::AuthProvider; + +#[component] +pub fn LoginPage() -> View { + let providers = create_signal(Vec::::new()); + let error = create_signal(None::); + let loading = create_signal(true); + + spawn_local(async move { + match api::auth_providers().await { + Ok(page) => { + if providers.is_alive() { + providers.set(page.providers); + } + } + Err(message) => { + if error.is_alive() { + error.set(Some(message)); + } + } + } + if loading.is_alive() { + loading.set(false); + } + }); + + let content = move || { + if loading.get() { + return spinner(); + } + let buttons: Vec = providers + .get_clone() + .into_iter() + .map(|provider| { + view! { + a(class="btn btn-primary btn-lg w-100 mb-2", href=provider.login_url, rel="external") { + "Sign in with " (provider.name) + } + } + }) + .collect(); + view! { + div(class="card mx-auto", style="max-width: 28rem;") { + div(class="card-body p-4") { + h1(class="h4 mb-2") { "Sign in to KCA" } + p(class="text-secondary") { + "KCA manages private keys and certificates. Sign in with your configured identity provider." + } + (buttons) + p(class="text-secondary small mb-0 mt-3") { + "The development stack accepts any username at the mock provider." + } + } + } + } + }; + + view! { + (app_nav("/login", None, || view! {})) + main(class="container page-shell py-5") { + (toast_error(error)) + (content) + } + } +} diff --git a/web/src/pages/mod.rs b/web/src/pages/mod.rs new file mode 100644 index 0000000..6d15616 --- /dev/null +++ b/web/src/pages/mod.rs @@ -0,0 +1,21 @@ +pub mod certificate_detail; +pub mod certificate_new; +pub mod certificates; +pub mod crl_detail; +pub mod crls; +pub mod dashboard; +pub mod import; +pub mod key_detail; +pub mod key_new; +pub mod keys; +pub mod login; +pub mod not_found; +pub mod request_detail; +pub mod request_new; +pub mod requests; +pub mod search; +pub mod settings; +pub mod template_editor; +pub mod templates; +pub mod tokens; +pub mod vault; diff --git a/web/src/pages/not_found.rs b/web/src/pages/not_found.rs new file mode 100644 index 0000000..ab19a5e --- /dev/null +++ b/web/src/pages/not_found.rs @@ -0,0 +1,19 @@ +use sycamore::prelude::*; + +use crate::components::nav::app_nav; + +#[component] +pub fn NotFoundPage() -> View { + view! { + (app_nav("", None, || view! {})) + main(class="container page-shell py-5") { + div(class="card") { + div(class="card-body p-4") { + h1(class="h3") { "Page not found" } + p(class="text-secondary") { "The page you requested does not exist." } + a(class="btn btn-primary", href="/") { "Back to vaults" } + } + } + } + } +} diff --git a/web/src/pages/request_detail.rs b/web/src/pages/request_detail.rs new file mode 100644 index 0000000..c9e5afe --- /dev/null +++ b/web/src/pages/request_detail.rs @@ -0,0 +1,232 @@ +//! Certificate signing request detail. +use serde_json::json; +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, spinner, whoami}; +use crate::components::ui::{empty_state, page_header, toast_error}; +use crate::dom; +use crate::model::{fmt_fp, Me, RequestDetail}; + +pub fn RequestDetailPage(vault_id: String, request_id: String) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let req_id: &'static str = Box::leak(request_id.clone().into_boxed_str()); + let me = create_signal(None::); + let request = create_signal(None::); + let error = create_signal(None::); + let loading = create_signal(true); + let name = create_signal(String::new()); + let comment = create_signal(String::new()); + let request_name = create_signal(String::new()); + let request_subject = create_signal(String::new()); + let signature_label = create_signal(String::new()); + let export_format = create_signal("pem".to_string()); + + spawn_local(async move { + if let Ok(user) = api::me().await { + me.set(Some(user)); + } + match api::request(id, req_id).await { + Ok(value) => { + name.set(value.summary.name.clone()); + comment.set(value.summary.comment.clone()); + request_name.set(value.summary.name.clone()); + request_subject.set(value.summary.subject.clone()); + signature_label.set(if value.signature_valid { "PKCS#10 signature valid".to_string() } else { "PKCS#10 signature INVALID".to_string() }); + request.set(Some(value)); + } + Err(message) => error.set(Some(message)), + } + loading.set(false); + }); + + let save = move |_| { + let value = request.get_clone(); + let Some(value) = value else { return }; + let payload = json!({"name": name.get_clone(), "comment": comment.get_clone(), "version": value.summary.version}); + spawn_local(async move { + match api::update_request(id, req_id, &payload).await { + Ok(updated) => { + request_name.set(updated.summary.name.clone()); + request.set(Some(updated)); + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let export = move |_| { + let fmt = export_format.get_clone(); + spawn_local(async move { + match api::export_request(id, req_id, &json!({"format": fmt})).await { + Ok(payload) => { + if let Err(message) = dom::download_base64(&payload.filename, &payload.content_type, &payload.content_base64) { + error.set(Some(message)); + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let remove = move |_| { + if !dom::confirm("Delete this certificate signing request?") { + return; + } + spawn_local(async move { + match api::delete_request(id, req_id).await { + Ok(()) => sycamore_router::navigate(&format!("/vaults/{id}/requests")), + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let extract = move |_| { + spawn_local(async move { + match api::transform_request(id, req_id, "open-key").await { + Ok(value) => { + if let Some(key_id) = value.get("id").and_then(|v| v.as_str()) { + sycamore_router::navigate(&format!("/vaults/{id}/keys/{key_id}")); + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let to_template = move |_| { + spawn_local(async move { + match api::transform_request(id, req_id, "template").await { + Ok(value) => { + if let Some(template_id) = value.get("id").and_then(|v| v.as_str()) { + sycamore_router::navigate(&format!("/vaults/{id}/templates/{template_id}")); + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + }; + + let content = move || { + if loading.get() { + return spinner(); + } + let Some(value) = request.get_clone() else { + return empty_state("Request not found."); + }; + let mut html = String::new(); + html.push_str(""); + html.push_str(&format!("", escape(&value.subject_rfc4514))); + html.push_str(&format!("", escape(&value.signature_algorithm))); + html.push_str(&format!("", fmt_fp(&value.summary.fingerprint_sha256))); + html.push_str(&format!("", value.summary.certificate_count)); + html.push_str("
    Subject{}
    Signature{}
    Fingerprint{}
    Certificates issued{}
    "); + let mut subject_html = String::new(); + for entry in &value.subject_entries { + subject_html.push_str(&format!("{}{}", escape(&entry.name), escape(&entry.value))); + } + let mut ext_html = String::new(); + for ext in &value.extensions { + ext_html.push_str(&format!( + "{}{}{}", + escape(&ext.name), + if ext.critical { " critical" } else { "" }, + escape(&ext.value), + )); + } + let mut attr_html = String::new(); + for attr in &value.attributes { + attr_html.push_str(&format!("{}{}", escape(&attr.name), escape(&attr.values.join(", ")))); + } + view! { + (page_header(request_name.get_clone(), request_subject.get_clone(), view! { + a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/new?request={req_id}")) { "Sign request" } + a(class="btn btn-outline-light", href=format!("/vaults/{id}/requests")) { "Back" } + button(class="btn btn-outline-danger", on:click=remove) { "Delete" } + })) + div(class="alert alert-info") { (signature_label.get_clone()) } + div(class="row g-3") { + div(class="col-12 col-lg-7") { + div(class="card mb-3") { + div(class="card-header") { "Request" } + div(class="card-body", dangerously_set_inner_html=html) {} + } + div(class="card mb-3") { + div(class="card-header") { "Subject" } + table(class="table table-sm mb-0") { tbody(dangerously_set_inner_html=subject_html) {} } + } + div(class="card mb-3") { + div(class="card-header") { "Requested extensions" } + div(class="table-responsive") { + table(class="table table-sm ext-table mb-0") { + thead() { tr() { th() { "Extension" } th() { "Value" } } } + tbody(dangerously_set_inner_html=ext_html) {} + } + } + } + div(class="card") { + div(class="card-header") { "Attributes" } + table(class="table table-sm mb-0") { tbody(dangerously_set_inner_html=attr_html) {} } + } + } + div(class="col-12 col-lg-5") { + div(class="card mb-3") { + div(class="card-header") { "Edit" } + div(class="card-body") { + div(class="mb-2") { + label(class="form-label") { "Name" } + input(class="form-control", bind:value=name) + } + div(class="mb-2") { + label(class="form-label") { "Comment" } + textarea(class="form-control", rows="3", bind:value=comment) {} + } + button(class="btn btn-primary", on:click=save) { "Save" } + } + } + div(class="card mb-3") { + div(class="card-header") { "Export" } + div(class="card-body") { + select(class="form-select mb-2", bind:value=export_format) { + option(value="pem") { "PEM" } + option(value="der") { "DER" } + option(value="openssl-config") { "OpenSSL config" } + } + button(class="btn btn-primary", on:click=export) { "Download" } + } + } + div(class="card") { + div(class="card-header") { "Transform" } + div(class="card-body d-grid gap-2") { + button(class="btn btn-outline-light", on:click=extract) { "Extract public key" } + button(class="btn btn-outline-light", on:click=to_template) { "Create template" } + } + } + } + } + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (content) + } + } +} + +fn escape(value: &str) -> String { + value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} diff --git a/web/src/pages/request_new.rs b/web/src/pages/request_new.rs new file mode 100644 index 0000000..c6a760b --- /dev/null +++ b/web/src/pages/request_new.rs @@ -0,0 +1,322 @@ +//! Create a PKCS#10 certificate signing request. +use serde_json::json; +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, whoami}; +use crate::components::ui::{page_header, toast_error}; +use crate::model::{KeySummary, Me}; + +pub fn RequestNewPage(vault_id: String) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let me = create_signal(None::); + let keys = create_signal(Vec::::new()); + let error = create_signal(None::); + let saving = create_signal(false); + + let name = create_signal(String::new()); + let comment = create_signal(String::new()); + let key_id = create_signal(String::new()); + let new_key = create_signal(false); + let key_type = create_signal("rsa".to_string()); + let key_size = create_signal("2048".to_string()); + let signature_hash = create_signal("sha256".to_string()); + + let cn = create_signal(String::new()); + let country = create_signal(String::new()); + let state = create_signal(String::new()); + let locality = create_signal(String::new()); + let organization = create_signal(String::new()); + let org_unit = create_signal(String::new()); + let email = create_signal(String::new()); + + let san_text = create_signal(String::new()); + let challenge = create_signal(String::new()); + let unstructured = create_signal(String::new()); + let ku_digital = create_signal(true); + let ku_encipher = create_signal(false); + let ku_cert_sign = create_signal(false); + let ku_crl_sign = create_signal(false); + let eku_server = create_signal(false); + let eku_client = create_signal(false); + let eku_code = create_signal(false); + let eku_email = create_signal(false); + + spawn_local(async move { + if let Ok(user) = api::me().await { + if me.is_alive() { + me.set(Some(user)); + } + } + if let Ok(page) = api::keys(&id).await { + if keys.is_alive() { + keys.set(page.results.into_iter().filter(|key| key.has_private).collect()); + } + } + }); + + let submit = move |_| { + let request_name = name.get_clone(); + if request_name.trim().is_empty() { + error.set(Some("request name is required".to_string())); + return; + } + let mut subject = Vec::new(); + for (key, value) in [ + ("C", country.get_clone()), + ("ST", state.get_clone()), + ("L", locality.get_clone()), + ("O", organization.get_clone()), + ("OU", org_unit.get_clone()), + ("CN", cn.get_clone()), + ("emailAddress", email.get_clone()), + ] { + if !value.trim().is_empty() { + subject.push(json!({"name": key, "value": value.trim()})); + } + } + if subject.is_empty() { + error.set(Some("the subject must not be empty".to_string())); + return; + } + let usages = [ + (ku_digital.get(), "digital_signature"), + (ku_encipher.get(), "key_encipherment"), + (ku_cert_sign.get(), "key_cert_sign"), + (ku_crl_sign.get(), "crl_sign"), + ] + .iter() + .filter(|(checked, _)| *checked) + .map(|(_, name)| json!(name)) + .collect::>(); + let eku = [ + (eku_server.get(), "serverAuth"), + (eku_client.get(), "clientAuth"), + (eku_code.get(), "codeSigning"), + (eku_email.get(), "emailProtection"), + ] + .iter() + .filter(|(checked, _)| *checked) + .map(|(_, name)| json!(name)) + .collect::>(); + let mut extensions = json!({ + "key_usage": {"critical": true, "usages": usages}, + "extended_key_usage": {"critical": false, "usages": eku}, + }); + let san = parse_san(&san_text.get_clone()); + if !san.is_empty() { + extensions["subject_alt_name"] = json!({"critical": false, "entries": san}); + } + let mut payload = json!({ + "name": request_name, + "comment": comment.get_clone(), + "subject": subject, + "extensions": extensions, + "signature_hash": signature_hash.get_clone(), + }); + if new_key.get() { + payload["new_key"] = json!({ + "key_type": key_type.get_clone(), + "size": key_size.get_clone().parse::().unwrap_or(2048), + "name": format!("{} key", name.get_clone()), + }); + } else if !key_id.get_clone().is_empty() { + payload["key_id"] = json!(key_id.get_clone()); + } else { + error.set(Some("select a key or generate a new one".to_string())); + return; + } + let mut attributes = serde_json::Map::new(); + if !challenge.get_clone().is_empty() { + attributes.insert("1.2.840.113549.1.9.7".to_string(), json!(challenge.get_clone())); + } + if !unstructured.get_clone().is_empty() { + attributes.insert("1.2.840.113549.1.9.2".to_string(), json!(unstructured.get_clone())); + } + payload["attributes"] = serde_json::Value::Object(attributes); + saving.set(true); + let id = id.clone(); + spawn_local(async move { + match api::create_request(&id, &payload).await { + Ok(request) => { + saving.set(false); + sycamore_router::navigate(&format!("/vaults/{}/requests/{}", id, request.summary.id)); + return; + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + if saving.is_alive() { saving.set(false); } + }); + }; + + let key_options = move || { + keys.get_clone() + .into_iter() + .map(|key| view! { option(value=key.id.clone()) { (key.name.clone()) " (" (key.key_type.clone()) ")" } }) + .collect::>() + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (page_header("New certificate signing request", + "The CSR contains the public key and requested extensions; it is signed by the private key.", + view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}/requests")) { "Cancel" } })) + div(class="card") { + div(class="card-body") { + div(class="form-section") { + div(class="form-section-title") { "General" } + div(class="row g-3") { + div(class="col-md-6") { + label(class="form-label") { "Internal name" } + input(class="form-control", bind:value=name) + } + div(class="col-md-6") { + label(class="form-label") { "Signature hash" } + select(class="form-select", bind:value=signature_hash) { + option(value="sha256") { "SHA-256" } + option(value="sha384") { "SHA-384" } + option(value="sha512") { "SHA-512" } + option(value="sha1") { "SHA-1" } + } + } + div(class="col-12") { + label(class="form-label") { "Comment" } + textarea(class="form-control", rows="2", bind:value=comment) {} + } + } + } + div(class="form-section") { + div(class="form-section-title") { "Subject" } + div(class="row g-3") { + (subject_input("Common name (CN)", cn)) + (subject_input("Organization (O)", organization)) + (subject_input("Organizational unit (OU)", org_unit)) + (subject_input("Country (C)", country)) + (subject_input("State / province (ST)", state)) + (subject_input("Locality (L)", locality)) + (subject_input("E-mail address", email)) + } + } + div(class="form-section") { + div(class="form-section-title") { "Private key" } + div(class="form-check mb-2") { + input(class="form-check-input", r#type="checkbox", id="new-key", bind:checked=new_key) + label(class="form-check-label", r#for="new-key") { "Generate a new key" } + } + (if new_key.get() { + view! { + div(class="row g-3") { + div(class="col-md-4") { + label(class="form-label") { "Key type" } + select(class="form-select", bind:value=key_type) { + option(value="rsa") { "RSA" } + option(value="ec") { "EC" } + option(value="ed25519") { "ED25519" } + } + } + div(class="col-md-4") { + label(class="form-label") { "Key size" } + input(class="form-control", bind:value=key_size) + } + } + } + } else { + view! { + label(class="form-label") { "Existing key" } + select(class="form-select", bind:value=key_id) { + option(value="") { "— select a key —" } + (key_options()) + } + } + }) + } + div(class="form-section") { + div(class="form-section-title") { "Requested extensions" } + div(class="row g-3") { + div(class="col-md-6") { + label(class="form-label") { "Subject alternative names" } + input(class="form-control", placeholder="DNS:example.com, IP:192.0.2.1", bind:value=san_text) + } + div(class="col-md-6") { + label(class="form-label") { "Challenge password" } + input(class="form-control", bind:value=challenge) + } + div(class="col-md-6") { + label(class="form-label") { "Unstructured name" } + input(class="form-control", bind:value=unstructured) + } + } + div(class="row g-3 mt-1") { + div(class="col-md-6") { + div(class="form-section-title") { "Key usage" } + (check("Digital signature", ku_digital)) + (check("Key encipherment", ku_encipher)) + (check("Certificate sign", ku_cert_sign)) + (check("CRL sign", ku_crl_sign)) + } + div(class="col-md-6") { + div(class="form-section-title") { "Extended key usage" } + (check("TLS server", eku_server)) + (check("TLS client", eku_client)) + (check("Code signing", eku_code)) + (check("E-mail protection", eku_email)) + } + } + } + button(class="btn btn-primary", disabled=saving.get(), on:click=submit) { + (if saving.get() { "Creating…" } else { "Create request" }) + } + } + } + } + } +} + +fn subject_input(label: &'static str, value: Signal) -> View { + view! { + div(class="col-md-6") { + label(class="form-label") { (label) } + input(class="form-control", bind:value=value) + } + } +} + +fn check(label: &'static str, value: Signal) -> View { + view! { + div(class="form-check") { + input(class="form-check-input", r#type="checkbox", bind:checked=value) + label(class="form-check-label") { (label) } + } + } +} + +fn parse_san(text: &str) -> Vec { + text.split(',') + .map(|chunk| chunk.trim()) + .filter(|chunk| !chunk.is_empty()) + .map(|chunk| { + if let Some((prefix, value)) = chunk.split_once(':') { + let kind = match prefix.trim().to_lowercase().as_str() { + "ip" => "ip", + "uri" => "uri", + "email" => "email", + "rid" => "rid", + "dns" => "dns", + _ => "dns", + }; + json!({"type": kind, "value": value.trim()}) + } else { + json!({"type": "dns", "value": chunk}) + } + }) + .collect() +} diff --git a/web/src/pages/requests.rs b/web/src/pages/requests.rs new file mode 100644 index 0000000..d8dfd96 --- /dev/null +++ b/web/src/pages/requests.rs @@ -0,0 +1,110 @@ +//! Certificate signing request list. +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, spinner, whoami}; +use crate::components::ui::{empty_state, page_header, toast_error}; +use crate::model::{fmt_fp, Me, RequestSummary}; + +pub fn RequestsPage(vault_id: String) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let me = create_signal(None::); + let requests = create_signal(Vec::::new()); + let error = create_signal(None::); + let loading = create_signal(true); + + spawn_local(async move { + match api::me().await { + Ok(user) => { + if me.is_alive() { + me.set(Some(user)); + } + match api::requests(&id).await { + Ok(page) => { + if requests.is_alive() { + requests.set(page.results); + } + } + Err(message) if message != api::LOGIN_REQUIRED => { + if error.is_alive() { + error.set(Some(message)); + } + } + _ => {} + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => { + if error.is_alive() { + error.set(Some(message)); + } + } + } + if loading.is_alive() { + loading.set(false); + } + }); + + let content = move || { + if loading.get() { + return spinner(); + } + let is_empty = requests.get_clone().is_empty(); + let html = requests.get_clone().into_iter().map(|request| { + format!( + "{}
    {}
    {}{}{}{}", + id, + escape(&request.id), + escape(&request.name), + escape(&request.comment), + escape(&request.subject), + if request.signed { "signed" } else { "new" }, + request.certificate_count, + fmt_fp(&request.fingerprint_sha256), + ) + }).collect::(); + let table = if is_empty { + view! { div(class="card") { div(class="card-body") { (empty_state("No certificate signing requests yet.")) } } } + } else { + view! { + div(class="card") { + div(class="table-responsive") { + table(class="table table-hover align-middle mb-0") { + thead() { tr() { th() { "Name" } th() { "Subject" } th() { "Status" } th() { "Certs" } th() { "SHA-256" } } } + tbody(dangerously_set_inner_html=html) {} + } + } + } + } + }; + view! { + (page_header( + "Certificate signing requests", + "PKCS#10 requests can be created here or imported; signing one marks it as used.", + view! { + a(class="btn btn-primary", href=format!("/vaults/{id}/requests/new")) { "New request" } + a(class="btn btn-outline-light", href=format!("/vaults/{id}/import")) { "Import" } + }, + )) + (table) + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (content) + } + } +} + +fn escape(value: &str) -> String { + value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} diff --git a/web/src/pages/search.rs b/web/src/pages/search.rs new file mode 100644 index 0000000..726ba0f --- /dev/null +++ b/web/src/pages/search.rs @@ -0,0 +1,145 @@ +//! Global vault search. +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, spinner, whoami}; +use crate::components::ui::{page_header, toast_error}; +use crate::model::{fmt_date, fmt_fp, Me}; + +pub fn SearchPage(vault_id: String) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let me = create_signal(None::); + let query = create_signal(String::new()); + let kind = create_signal("all".to_string()); + let results_html = create_signal(String::new()); + let error = create_signal(None::); + let loading = create_signal(true); + let searched = create_signal(false); + + spawn_local(async move { + if let Ok(user) = api::me().await { + me.set(Some(user)); + } + loading.set(false); + }); + + let run = move |_| { + let q = query.get_clone(); + let kind = kind.get_clone(); + if q.trim().is_empty() { + results_html.set(String::new()); + searched.set(false); + return; + } + loading.set(true); + spawn_local(async move { + match api::search(id, &q, &kind).await { + Ok(page) => { + results_html.set(search_rows_html(id, &page.results)); + searched.set(true); + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + loading.set(false); + }); + }; + + let content = move || { + if loading.get() { + return spinner(); + } + let results = results_html.get_clone(); + let results = if results.is_empty() { + if searched.get() { "

    No matches.

    ".to_string() } else { "

    Type a query to search.

    ".to_string() } + } else { results }; + view! { + (page_header( + "Search", + "Searches names, subjects, issuers, serials, fingerprints and extension text in this vault.", + view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}")) { "Back" } }, + )) + div(class="card mb-3") { + div(class="card-body") { + div(class="row g-2") { + div(class="col-md-8") { + input(class="form-control", placeholder="Search…", bind:value=query) + } + div(class="col-md-2") { + select(class="form-select", bind:value=kind) { + option(value="all") { "All" } + option(value="certificates") { "Certificates" } + option(value="keys") { "Keys" } + option(value="requests") { "Requests" } + option(value="crls") { "CRLs" } + option(value="templates") { "Templates" } + } + } + div(class="col-md-2") { + button(class="btn btn-primary w-100", on:click=run) { "Search" } + } + } + } + } + div(class="card", dangerously_set_inner_html=results) {} + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (content) + } + } +} + +fn search_rows_html(vault_id: &str, results: &[crate::model::SearchResult]) -> String { + results.iter().map(|result| { + let item = &result.item; + let item_id = item.get("id").and_then(|v| v.as_str()).unwrap_or_default(); + let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("(unnamed)"); + let route = match result.r#type.as_str() { + "certificate" => "certificates", + "key" => "keys", + "request" => "requests", + "crl" => "crls", + "template" => "templates", + _ => "certificates", + }; + let subtitle = if result.r#type == "certificate" { + let subject = item.get("subject").and_then(|v| v.as_str()).unwrap_or(""); + let expiry = item.get("not_after").and_then(|v| v.as_str()).map(|s| s.to_string()); + format!("{subject} · expires {}", fmt_date(&expiry)) + } else if result.r#type == "key" { + let key_type = item.get("key_type").and_then(|v| v.as_str()).unwrap_or(""); + let fp = item.get("fingerprint_sha256").and_then(|v| v.as_str()).unwrap_or(""); + format!("{key_type} · {}", fmt_fp(fp)) + } else if result.r#type == "request" { + item.get("subject").and_then(|v| v.as_str()).unwrap_or("").to_string() + } else if result.r#type == "crl" { + item.get("issuer_name").and_then(|v| v.as_str()).unwrap_or("").to_string() + } else { + item.get("comment").and_then(|v| v.as_str()).unwrap_or("").to_string() + }; + format!( + "
    {}
    {}
    {}
    ", + escape(vault_id), + route, + escape(item_id), + escape(name), + escape(&subtitle), + escape(&result.r#type), + ) + }).collect() +} + +fn escape(value: &str) -> String { + value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} diff --git a/web/src/pages/settings.rs b/web/src/pages/settings.rs new file mode 100644 index 0000000..e560950 --- /dev/null +++ b/web/src/pages/settings.rs @@ -0,0 +1,220 @@ +//! Per-vault options, mirroring XCA's database settings. +use serde_json::json; +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, spinner, whoami}; +use crate::components::ui::{page_header, toast_error}; +use crate::model::Me; + +pub fn SettingsPage(vault_id: String) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let me = create_signal(None::); + let error = create_signal(None::); + let info = create_signal(None::); + let loading = create_signal(true); + let saving = create_signal(false); + let version = create_signal(0i64); + + let default_hash = create_signal("sha256".to_string()); + let serial_len = create_signal("64".to_string()); + let explicit_dn = create_signal("C,ST,L,O,OU,CN,emailAddress".to_string()); + let mandatory_dn = create_signal(String::new()); + let disable_netscape = create_signal(false); + let hide_unusable = create_signal(false); + let default_key_type = create_signal("rsa".to_string()); + let default_key_size = create_signal("2048".to_string()); + let default_validity = create_signal("365".to_string()); + let default_validity_unit = create_signal("days".to_string()); + + spawn_local(async move { + if let Ok(user) = api::me().await { + me.set(Some(user)); + } + match api::vault_settings(&id).await { + Ok(value) => { + version.set(value.get("version").and_then(|v| v.as_i64()).unwrap_or(0)); + if let Some(settings) = value.get("settings") { + if let Some(v) = settings.get("default_hash").and_then(|v| v.as_str()) { + default_hash.set(v.to_string()); + } + if let Some(v) = settings.get("serial_len_bits").and_then(|v| v.as_i64()) { + serial_len.set(v.to_string()); + } + if let Some(items) = settings.get("explicit_dn").and_then(|v| v.as_array()) { + explicit_dn.set(items.iter().filter_map(|v| v.as_str()).collect::>().join(",")); + } + if let Some(items) = settings.get("mandatory_dn").and_then(|v| v.as_array()) { + mandatory_dn.set(items.iter().filter_map(|v| v.as_str()).collect::>().join(",")); + } + disable_netscape.set(settings.get("disable_netscape").and_then(|v| v.as_bool()).unwrap_or(false)); + hide_unusable.set(settings.get("hide_unusable").and_then(|v| v.as_bool()).unwrap_or(false)); + if let Some(key) = settings.get("default_key") { + if let Some(v) = key.get("type").and_then(|v| v.as_str()) { + default_key_type.set(v.to_string()); + } + if let Some(v) = key.get("size").and_then(|v| v.as_i64()) { + default_key_size.set(v.to_string()); + } + } + if let Some(validity) = settings.get("default_validity") { + if let Some(v) = validity.get("amount").and_then(|v| v.as_i64()) { + default_validity.set(v.to_string()); + } + if let Some(v) = validity.get("unit").and_then(|v| v.as_str()) { + default_validity_unit.set(v.to_string()); + } + } + } + } + Err(message) => error.set(Some(message)), + } + loading.set(false); + }); + + let save = { + let id = id.clone(); + move |_| { + let id = id.clone(); + let settings = json!({ + "default_hash": default_hash.get_clone(), + "serial_len_bits": serial_len.get_clone().parse::().unwrap_or(64), + "explicit_dn": explicit_dn.get_clone().split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect::>(), + "mandatory_dn": mandatory_dn.get_clone().split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect::>(), + "disable_netscape": disable_netscape.get(), + "hide_unusable": hide_unusable.get(), + "default_key": { + "type": default_key_type.get_clone(), + "size": default_key_size.get_clone().parse::().unwrap_or(2048), + "curve": null, + }, + "default_validity": { + "amount": default_validity.get_clone().parse::().unwrap_or(365), + "unit": default_validity_unit.get_clone(), + }, + }); + saving.set(true); + spawn_local(async move { + match api::update_vault_settings(&id, &settings, version.get()).await { + Ok(value) => { + version.set(value.get("version").and_then(|v| v.as_i64()).unwrap_or(version.get())); + info.set(Some("Settings saved.".to_string())); + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + saving.set(false); + }); + } + }; + + let content = move || { + if loading.get() { + return spinner(); + } + view! { + (page_header( + "Vault settings", + "These options are stored with the vault and mirror XCA's database preferences.", + view! { a(class="btn btn-outline-light", href=format!("/vaults/{id}")) { "Back" } }, + )) + (if let Some(message) = info.get_clone() { + view! { div(class="alert alert-success") { (message) } } + } else { + view! {} + }) + div(class="card") { + div(class="card-body") { + div(class="form-section") { + div(class="form-section-title") { "Cryptography" } + div(class="row g-3") { + div(class="col-md-4") { + label(class="form-label") { "Default signature hash" } + select(class="form-select", bind:value=default_hash) { + option(value="sha256") { "SHA-256" } + option(value="sha384") { "SHA-384" } + option(value="sha512") { "SHA-512" } + option(value="sha224") { "SHA-224" } + option(value="sha1") { "SHA-1" } + } + } + div(class="col-md-4") { + label(class="form-label") { "Serial number length (bits)" } + input(class="form-control", bind:value=serial_len) + } + div(class="col-md-4") { + label(class="form-label") { "Default validity" } + div(class="input-group") { + input(class="form-control", bind:value=default_validity) + select(class="form-select", bind:value=default_validity_unit) { + option(value="days") { "Days" } + option(value="months") { "Months" } + option(value="years") { "Years" } + } + } + } + } + } + div(class="form-section") { + div(class="form-section-title") { "Distinguished name" } + div(class="row g-3") { + div(class="col-md-6") { + label(class="form-label") { "Explicit subject entries" } + input(class="form-control", bind:value=explicit_dn) + div(class="form-text") { "Comma-separated short names, e.g. C,ST,L,O,OU,CN,emailAddress." } + } + div(class="col-md-6") { + label(class="form-label") { "Mandatory subject entries" } + input(class="form-control", bind:value=mandatory_dn) + } + } + } + div(class="form-section") { + div(class="form-section-title") { "Editor defaults" } + div(class="row g-3") { + div(class="col-md-4") { + label(class="form-label") { "Default key type" } + select(class="form-select", bind:value=default_key_type) { + option(value="rsa") { "RSA" } + option(value="ec") { "EC" } + option(value="ed25519") { "ED25519" } + } + } + div(class="col-md-4") { + label(class="form-label") { "Default key size" } + input(class="form-control", bind:value=default_key_size) + } + div(class="col-md-4 d-flex align-items-end") { + button(class="btn btn-primary", disabled=saving.get(), on:click=save) { + (if saving.get() { "Saving…" } else { "Save settings" }) + } + } + } + div(class="form-check mt-3") { + input(class="form-check-input", r#type="checkbox", bind:checked=disable_netscape) + label(class="form-check-label") { "Disable legacy Netscape extensions" } + } + div(class="form-check") { + input(class="form-check-input", r#type="checkbox", bind:checked=hide_unusable) + label(class="form-check-label") { "Hide expired and revoked certificates by default" } + } + } + } + } + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (content) + } + } +} diff --git a/web/src/pages/template_editor.rs b/web/src/pages/template_editor.rs new file mode 100644 index 0000000..845cc69 --- /dev/null +++ b/web/src/pages/template_editor.rs @@ -0,0 +1,422 @@ +//! Template editor (create/update; built-ins are read-only). +use serde_json::json; +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, spinner, whoami}; +use crate::components::ui::{page_header, toast_error}; +use crate::dom; +use crate::model::{Me, TemplateSummary}; + +pub fn TemplateEditorPage(vault_id: String, template_id: Option) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let template_id_value: Option<&'static str> = template_id.as_ref().map(|value| Box::leak(value.clone().into_boxed_str()) as &'static str); + let me = create_signal(None::); + let template = create_signal(None::); + let error = create_signal(None::); + let loading = create_signal(template_id.is_none()); + let saving = create_signal(false); + let builtin = create_signal(false); + + let name = create_signal(String::new()); + let comment = create_signal(String::new()); + let cn = create_signal(String::new()); + let organization = create_signal(String::new()); + let org_unit = create_signal(String::new()); + let country = create_signal(String::new()); + let amount = create_signal("365".to_string()); + let unit = create_signal("days".to_string()); + let midnight = create_signal(false); + let ca_mode = create_signal("end".to_string()); + let path_length = create_signal(String::new()); + let bc_critical = create_signal(true); + let ku_digital = create_signal(true); + let ku_encipher = create_signal(false); + let ku_key_agreement = create_signal(false); + let ku_cert_sign = create_signal(false); + let ku_crl_sign = create_signal(false); + let ku_critical = create_signal(true); + let eku_server = create_signal(false); + let eku_client = create_signal(false); + let eku_code = create_signal(false); + let eku_email = create_signal(false); + let eku_critical = create_signal(false); + let san_text = create_signal(String::new()); + let ski = create_signal(true); + let aki = create_signal(true); + + if let Some(template_id) = template_id.clone() { + spawn_local(async move { + if let Ok(user) = api::me().await { + me.set(Some(user)); + } + match api::template(&id, &template_id).await { + Ok(value) => { + builtin.set(value.builtin); + name.set(value.name.clone()); + comment.set(value.comment.clone()); + if let Some(subject) = value.subject.as_array() { + for entry in subject { + let key = entry.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let val = entry.get("value").and_then(|v| v.as_str()).unwrap_or("").to_string(); + match key { + "CN" => cn.set(val), + "O" => organization.set(val), + "OU" => org_unit.set(val), + "C" => country.set(val), + _ => {} + } + } + } + if let Some(validity) = value.settings.get("validity") { + if let Some(v) = validity.get("amount").and_then(|v| v.as_i64()) { + amount.set(v.to_string()); + } + if let Some(v) = validity.get("unit").and_then(|v| v.as_str()) { + unit.set(v.to_string()); + } + midnight.set(validity.get("midnight").and_then(|v| v.as_bool()).unwrap_or(false)); + } + if let Some(extensions) = value.settings.get("extensions") { + if let Some(bc) = extensions.get("basic_constraints") { + if bc.get("ca").and_then(|v| v.as_bool()).unwrap_or(false) { + ca_mode.set("ca".to_string()); + } else if bc.get("ca").is_some() { + ca_mode.set("end".to_string()); + } else { + ca_mode.set("none".to_string()); + } + bc_critical.set(bc.get("critical").and_then(|v| v.as_bool()).unwrap_or(true)); + if let Some(len) = bc.get("path_length").and_then(|v| v.as_i64()) { + path_length.set(len.to_string()); + } + } + if let Some(ku) = extensions.get("key_usage") { + let usages = string_array(ku.get("usages")); + ku_digital.set(usages.iter().any(|s| s == "digital_signature")); + ku_encipher.set(usages.iter().any(|s| s == "key_encipherment")); + ku_key_agreement.set(usages.iter().any(|s| s == "key_agreement")); + ku_cert_sign.set(usages.iter().any(|s| s == "key_cert_sign")); + ku_crl_sign.set(usages.iter().any(|s| s == "crl_sign")); + ku_critical.set(ku.get("critical").and_then(|v| v.as_bool()).unwrap_or(true)); + } + if let Some(eku) = extensions.get("extended_key_usage") { + let usages = string_array(eku.get("usages")); + eku_server.set(usages.iter().any(|s| s == "serverAuth")); + eku_client.set(usages.iter().any(|s| s == "clientAuth")); + eku_code.set(usages.iter().any(|s| s == "codeSigning")); + eku_email.set(usages.iter().any(|s| s == "emailProtection")); + eku_critical.set(eku.get("critical").and_then(|v| v.as_bool()).unwrap_or(false)); + } + if let Some(san) = extensions.get("subject_alt_name") { + let entries = san.get("entries").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let text = entries.iter().filter_map(|entry| { + let kind = entry.get("type").and_then(|v| v.as_str()).unwrap_or("dns"); + let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or(""); + if value.is_empty() { None } else { Some(format!("{}:{}", kind.to_uppercase(), value)) } + }).collect::>().join(", "); + san_text.set(text); + } + ski.set(extensions.get("subject_key_identifier").is_some()); + aki.set(extensions.get("authority_key_identifier").is_some()); + } + template.set(Some(value)); + } + Err(message) => error.set(Some(message)), + } + loading.set(false); + }); + } else { + spawn_local(async move { + if let Ok(user) = api::me().await { + me.set(Some(user)); + } + loading.set(false); + }); + } + + let submit = { + let id = id.clone(); + let template_id: Option<&'static str> = template_id.as_ref().map(|value| Box::leak(value.clone().into_boxed_str()) as &'static str); + move |_| { + let id = id.clone(); + let template_id = template_id.clone(); + let mut subject = Vec::new(); + for (key, value) in [ + ("C", country.get_clone()), + ("O", organization.get_clone()), + ("OU", org_unit.get_clone()), + ("CN", cn.get_clone()), + ] { + if !value.trim().is_empty() { + subject.push(json!({"name": key, "value": value.trim()})); + } + } + let mut extensions = json!({ + "key_usage": {"critical": ku_critical.get(), "usages": [ + if ku_digital.get() { json!("digital_signature") } else { json!(null) }, + if ku_encipher.get() { json!("key_encipherment") } else { json!(null) }, + if ku_key_agreement.get() { json!("key_agreement") } else { json!(null) }, + if ku_cert_sign.get() { json!("key_cert_sign") } else { json!(null) }, + if ku_crl_sign.get() { json!("crl_sign") } else { json!(null) }, + ]}, + "extended_key_usage": {"critical": eku_critical.get(), "usages": [ + if eku_server.get() { json!("serverAuth") } else { json!(null) }, + if eku_client.get() { json!("clientAuth") } else { json!(null) }, + if eku_code.get() { json!("codeSigning") } else { json!(null) }, + if eku_email.get() { json!("emailProtection") } else { json!(null) }, + ]}, + "subject_key_identifier": if ski.get() { json!({"critical": false}) } else { json!(null) }, + "authority_key_identifier": if aki.get() { json!({"critical": false}) } else { json!(null) }, + }); + // Drop null placeholders from the usage arrays. + for key in ["key_usage", "extended_key_usage"] { + if let Some(usages) = extensions[key]["usages"].as_array_mut() { + usages.retain(|value| !value.is_null()); + } + } + let mode = ca_mode.get_clone(); + if mode == "ca" { + extensions["basic_constraints"] = json!({"critical": bc_critical.get(), "ca": true, "path_length": path_length.get_clone().parse::().ok()}); + } else if mode == "end" { + extensions["basic_constraints"] = json!({"critical": bc_critical.get(), "ca": false, "path_length": null}); + } else { + extensions["basic_constraints"] = serde_json::Value::Null; + } + let san = parse_san(&san_text.get_clone()); + extensions["subject_alt_name"] = if san.is_empty() { serde_json::Value::Null } else { json!({"critical": false, "entries": san}) }; + let payload = json!({ + "name": name.get_clone(), + "comment": comment.get_clone(), + "subject": subject, + "settings": { + "extensions": extensions, + "validity": {"amount": amount.get_clone().parse::().unwrap_or(365), "unit": unit.get_clone(), "midnight": midnight.get()}, + }, + }); + saving.set(true); + spawn_local(async move { + let result = match template_id { + Some(template_id) => api::update_template(&id, &template_id, &payload).await, + None => api::create_template(&id, &payload).await, + }; + match result { + Ok(value) => { + saving.set(false); + sycamore_router::navigate(&format!("/vaults/{}/templates/{}", id, value.id)); + return; + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + if saving.is_alive() { saving.set(false); } + }); + } + }; + + let export = move |_| { + if let Some(template_id) = template_id_value { + spawn_local(async move { + match api::export_template(id, template_id, &json!({"format": "xca"})).await { + Ok(payload) => { + if let Err(message) = dom::download_base64(&payload.filename, &payload.content_type, &payload.content_base64) { + error.set(Some(message)); + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + } + }; + + let content = move || { + if loading.get() { + return spinner(); + } + let read_only = builtin.get(); + view! { + (page_header( + if template_id_value.is_some() { "Template" } else { "New template" }, + "Templates store subject defaults and extension presets; applying one never signs anything.", + view! { + a(class="btn btn-outline-light", href=format!("/vaults/{id}/templates")) { "Cancel" } + (if template_id_value.is_some() { + view! { button(class="btn btn-outline-light", on:click=export) { "Export .xca" } } + } else { + view! {} + }) + }, + )) + (if read_only { + view! { div(class="alert alert-info") { "This is a built-in template. Create a new template to customise it." } } + } else { + view! {} + }) + div(class="card") { + div(class="card-body") { + div(class="form-section") { + div(class="form-section-title") { "General" } + div(class="row g-3") { + div(class="col-md-6") { + label(class="form-label") { "Name" } + input(class="form-control", disabled=read_only, bind:value=name) + } + div(class="col-12") { + label(class="form-label") { "Comment" } + textarea(class="form-control", rows="2", disabled=read_only, bind:value=comment) {} + } + } + } + div(class="form-section") { + div(class="form-section-title") { "Subject defaults" } + div(class="row g-3") { + (subject_input("Common name (CN)", cn, read_only)) + (subject_input("Organization (O)", organization, read_only)) + (subject_input("Organizational unit (OU)", org_unit, read_only)) + (subject_input("Country (C)", country, read_only)) + } + } + div(class="form-section") { + div(class="form-section-title") { "Extensions" } + div(class="row g-3") { + div(class="col-md-4") { + label(class="form-label") { "Basic constraints" } + select(class="form-select", disabled=read_only, bind:value=ca_mode) { + option(value="none") { "Not defined" } + option(value="end") { "End entity (CA:FALSE)" } + option(value="ca") { "Certification authority (CA:TRUE)" } + } + } + div(class="col-md-3") { + label(class="form-label") { "Path length" } + input(class="form-control", disabled=read_only, bind:value=path_length) + } + div(class="col-md-3 d-flex align-items-end") { + (check("Critical", bc_critical, read_only)) + } + } + div(class="row g-3 mt-1") { + div(class="col-md-6") { + div(class="form-section-title") { "Key usage" } + (check("Digital signature", ku_digital, read_only)) + (check("Key encipherment", ku_encipher, read_only)) + (check("Key agreement", ku_key_agreement, read_only)) + (check("Certificate sign", ku_cert_sign, read_only)) + (check("CRL sign", ku_crl_sign, read_only)) + (check("Critical", ku_critical, read_only)) + } + div(class="col-md-6") { + div(class="form-section-title") { "Extended key usage" } + (check("TLS server", eku_server, read_only)) + (check("TLS client", eku_client, read_only)) + (check("Code signing", eku_code, read_only)) + (check("E-mail protection", eku_email, read_only)) + (check("Critical", eku_critical, read_only)) + } + div(class="col-md-6") { + div(class="form-section-title") { "Subject alternative names" } + input(class="form-control", disabled=read_only, bind:value=san_text) + } + div(class="col-md-6") { + div(class="form-section-title") { "Key identifiers" } + (check("Subject key identifier", ski, read_only)) + (check("Authority key identifier", aki, read_only)) + } + } + } + div(class="form-section") { + div(class="form-section-title") { "Default validity" } + div(class="row g-3") { + div(class="col-md-3") { + label(class="form-label") { "Duration" } + input(class="form-control", disabled=read_only, bind:value=amount) + } + div(class="col-md-3") { + label(class="form-label") { "Unit" } + select(class="form-select", disabled=read_only, bind:value=unit) { + option(value="days") { "Days" } + option(value="months") { "Months" } + option(value="years") { "Years" } + } + } + div(class="col-md-3 d-flex align-items-end") { + (check("Round to midnight", midnight, read_only)) + } + } + } + (if read_only { + view! {} + } else { + view! { + button(class="btn btn-primary", disabled=saving.get(), on:click=submit.clone()) { + (if saving.get() { "Saving…" } else { "Save template" }) + } + } + }) + } + } + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (content) + } + } +} + +fn subject_input(label: &'static str, value: Signal, disabled: bool) -> View { + view! { + div(class="col-md-6") { + label(class="form-label") { (label) } + input(class="form-control", disabled=disabled, bind:value=value) + } + } +} + +fn check(label: &'static str, value: Signal, disabled: bool) -> View { + view! { + div(class="form-check") { + input(class="form-check-input", r#type="checkbox", disabled=disabled, bind:checked=value) + label(class="form-check-label") { (label) } + } + } +} + +fn parse_san(text: &str) -> Vec { + text.split(',') + .map(|chunk| chunk.trim()) + .filter(|chunk| !chunk.is_empty()) + .map(|chunk| { + if let Some((prefix, value)) = chunk.split_once(':') { + let kind = match prefix.trim().to_lowercase().as_str() { + "ip" => "ip", + "uri" => "uri", + "email" => "email", + "rid" => "rid", + "dns" => "dns", + _ => "dns", + }; + json!({"type": kind, "value": value.trim()}) + } else { + json!({"type": "dns", "value": chunk}) + } + }) + .collect() +} + +fn string_array(value: Option<&serde_json::Value>) -> Vec { + value + .and_then(|v| v.as_array()) + .map(|items| items.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) + .unwrap_or_default() +} diff --git a/web/src/pages/templates.rs b/web/src/pages/templates.rs new file mode 100644 index 0000000..233687f --- /dev/null +++ b/web/src/pages/templates.rs @@ -0,0 +1,105 @@ +//! Template list. +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, spinner, whoami}; +use crate::components::ui::{empty_state, page_header, toast_error}; +use crate::model::{Me, TemplateSummary}; + +pub fn TemplatesPage(vault_id: String) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let me = create_signal(None::); + let templates = create_signal(Vec::::new()); + let error = create_signal(None::); + let loading = create_signal(true); + + spawn_local(async move { + match api::me().await { + Ok(user) => { + if me.is_alive() { + me.set(Some(user)); + } + match api::templates(&id).await { + Ok(page) => { + if templates.is_alive() { + templates.set(page.results); + } + } + Err(message) if message != api::LOGIN_REQUIRED => { + if error.is_alive() { + error.set(Some(message)); + } + } + _ => {} + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => { + if error.is_alive() { + error.set(Some(message)); + } + } + } + if loading.is_alive() { + loading.set(false); + } + }); + + let content = move || { + if loading.get() { + return spinner(); + } + let is_empty = templates.get_clone().is_empty(); + let html = templates.get_clone().into_iter().map(|template| { + format!( + "{}{}
    {}
    {}", + id, + escape(&template.id), + escape(&template.name), + if template.builtin { " built-in" } else { "" }, + escape(&template.comment), + escape(&template.source), + ) + }).collect::(); + let table = if is_empty { + view! { div(class="card") { div(class="card-body") { (empty_state("No templates yet.")) } } } + } else { + view! { + div(class="card") { + div(class="table-responsive") { + table(class="table table-hover align-middle mb-0") { + thead() { tr() { th() { "Name" } th() { "Source" } } } + tbody(dangerously_set_inner_html=html) {} + } + } + } + } + }; + view! { + (page_header( + "Templates", + "Templates are reusable subject and extension presets. Built-ins mirror XCA's CA, TLS server and TLS client templates.", + view! { a(class="btn btn-primary", href=format!("/vaults/{id}/templates/new")) { "New template" } }, + )) + (table) + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (content) + } + } +} + +fn escape(value: &str) -> String { + value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} diff --git a/web/src/pages/tokens.rs b/web/src/pages/tokens.rs new file mode 100644 index 0000000..20f5b54 --- /dev/null +++ b/web/src/pages/tokens.rs @@ -0,0 +1,169 @@ +//! Personal API tokens. +use sycamore::prelude::*; +use wasm_bindgen::JsCast; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, spinner, whoami}; +use crate::components::ui::{copy_button, page_header, toast_error}; +use crate::dom; +use crate::model::{ApiToken, CreatedApiToken, Me}; + +#[component] +pub fn TokensPage() -> View { + let me = create_signal(None::); + let tokens = create_signal(Vec::::new()); + let created = create_signal(None::); + let error = create_signal(None::); + let loading = create_signal(true); + let name = create_signal(String::new()); + let expires = create_signal(String::new()); + let busy = create_signal(false); + + spawn_local(async move { + match api::me().await { + Ok(user) => { + if me.is_alive() { + me.set(Some(user)); + } + match api::tokens().await { + Ok(page) => tokens.set(page.results), + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + loading.set(false); + }); + + let reload = move || { + spawn_local(async move { + if let Ok(page) = api::tokens().await { + tokens.set(page.results); + } + }); + }; + + let create = move |_| { + let token_name = name.get_clone(); + if token_name.trim().is_empty() { + error.set(Some("token name is required".to_string())); + return; + } + let expiry = expires.get_clone(); + busy.set(true); + spawn_local(async move { + match api::create_token(&token_name, if expiry.is_empty() { None } else { Some(&expiry) }).await { + Ok(token) => { + created.set(Some(token)); + name.set(String::new()); + expires.set(String::new()); + reload(); + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + busy.set(false); + }); + }; + + let content = move || { + if loading.get() { + return spinner(); + } + let html = tokens.get_clone().into_iter().map(|token| { + format!( + "{}{}…{}{}{}", + escape(&token.name), + escape(&token.prefix), + escape(&token.created_at), + escape(token.last_used_at.as_deref().unwrap_or("never")), + escape(token.expires_at.as_deref().unwrap_or("never")), + escape(&token.id), + ) + }).collect::(); + let created_panel = if let Some(token) = created.get_clone() { + let token_code = token.token.clone(); + let token_copy = token.token.clone(); + view! { + div(class="alert alert-success") { + div(class="fw-bold mb-1") { "Token created — copy it now, it is shown only once." } + div(class="d-flex align-items-center gap-2 flex-wrap") { + code(class="text-break") { (token_code.clone()) } + (copy_button(token_copy.clone(), "Copy")) + } + } + } + } else { + view! {} + }; + view! { + (page_header( + "API tokens", + "Programs authenticate with Authorization: Bearer kca_… and act as their owner.", + view! { a(class="btn btn-outline-light", href="/") { "Back" } }, + )) + (created_panel) + div(class="card mb-3") { + div(class="card-header") { "Create token" } + div(class="card-body") { + div(class="row g-2 align-items-end") { + div(class="col-md-5") { + label(class="form-label") { "Name" } + input(class="form-control", placeholder="CI uploader", bind:value=name) + } + div(class="col-md-4") { + label(class="form-label") { "Expires (optional)" } + input(class="form-control", r#type="date", bind:value=expires) + } + div(class="col-md-3") { + button(class="btn btn-primary w-100", disabled=busy.get(), on:click=create) { "Create" } + } + } + } + } + div(class="card") { + div(class="card-header") { "Tokens" } + div(class="table-responsive", on:click=move |event: web_sys::MouseEvent| { + if let Some(target) = event.target().and_then(|t| t.dyn_into::().ok()) { + if let Some(token_id) = target.get_attribute("data-token-id") { + if dom::confirm("Revoke this API token?") { + spawn_local(async move { + match api::delete_token(&token_id).await { + Ok(()) => reload(), + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => error.set(Some(message)), + } + }); + } + } + } + }) { + table(class="table table-sm align-middle mb-0") { + thead() { tr() { th() { "Name" } th() { "Prefix" } th() { "Created" } th() { "Last used" } th() { "Expires" } th() {} } } + tbody(dangerously_set_inner_html=html) {} + } + } + } + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", None, nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (content) + } + } +} + +fn escape(value: &str) -> String { + value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} diff --git a/web/src/pages/vault.rs b/web/src/pages/vault.rs new file mode 100644 index 0000000..5029799 --- /dev/null +++ b/web/src/pages/vault.rs @@ -0,0 +1,173 @@ +//! One vault's overview: counts, expiring certificates, CRLs due. +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::api; +use crate::components::nav::{app_nav, spinner, whoami}; +use crate::components::ui::{empty_state, page_header, toast_error}; +use crate::model::{fmt_date, item_total, Me, SummaryPage, VaultDetail}; + +pub fn VaultPage(vault_id: String) -> View { + let id: &'static str = Box::leak(vault_id.clone().into_boxed_str()); + let me = create_signal(None::); + let vault = create_signal(None::); + let summary = create_signal(None::); + let error = create_signal(None::); + let loading = create_signal(true); + + spawn_local(async move { + match api::me().await { + Ok(user) => { + if me.is_alive() { + me.set(Some(user)); + } + match api::vault(&id).await { + Ok(value) => { + if vault.is_alive() { + vault.set(Some(value)); + } + } + Err(message) => { + if error.is_alive() { + error.set(Some(message)); + } + } + } + match api::vault_summary(&id).await { + Ok(value) => { + if summary.is_alive() { + summary.set(Some(value)); + } + } + Err(message) if message != api::LOGIN_REQUIRED => { + if error.is_alive() { + error.set(Some(message)); + } + } + _ => {} + } + } + Err(message) if message == api::LOGIN_REQUIRED => {} + Err(message) => { + if error.is_alive() { + error.set(Some(message)); + } + } + } + if loading.is_alive() { + loading.set(false); + } + }); + + let content = move || { + if loading.get() { + return spinner(); + } + let Some(vault) = vault.get_clone() else { + return empty_state("Vault not found."); + }; + let Some(summary) = summary.get_clone() else { + return spinner(); + }; + let id = id; + let counts = vault.counts.clone(); + let expiring_html = summary.expiring.iter().map(|cert| { + format!( + "
  • {}
    {}
    {}
  • ", + id, escape(&cert.id), escape(&cert.name), escape(&cert.subject), fmt_date(&cert.not_after) + ) + }).collect::(); + let recent_html = summary.recent_certificates.iter().map(|cert| { + format!( + "
  • {}
    {}
    {}
  • ", + id, escape(&cert.id), escape(&cert.name), escape(&cert.subject), if cert.is_ca { "CA" } else { "cert" } + ) + }).collect::(); + let due_html = summary.crls_due.iter().map(|crl| { + format!( + "
  • {}due {}
  • ", + id, escape(&crl.id), escape(&crl.name), fmt_date(&crl.next_update) + ) + }).collect::(); + view! { + (page_header( + &vault.name, + if vault.description.is_empty() { "Private PKI vault" } else { &vault.description }, + view! { + a(class="btn btn-primary", href=format!("/vaults/{id}/certificates/new")) { "New certificate" } + a(class="btn btn-outline-light", href=format!("/vaults/{id}/keys/new")) { "New key" } + a(class="btn btn-outline-light", href=format!("/vaults/{id}/import")) { "Import" } + a(class="btn btn-outline-light", href=format!("/vaults/{id}/settings")) { "Settings" } + }, + )) + div(class="row g-3 mb-4") { + (stat_card("Certificates", counts.certificates, format!("/vaults/{id}/certificates"))) + (stat_card("Requests", counts.requests, format!("/vaults/{id}/requests"))) + (stat_card("Keys", counts.keys, format!("/vaults/{id}/keys"))) + (stat_card("CRLs", counts.crls, format!("/vaults/{id}/crls"))) + (stat_card("Templates", counts.templates, format!("/vaults/{id}/templates"))) + (stat_card("Total items", item_total(&counts), format!("/vaults/{id}/search"))) + } + div(class="row g-3") { + div(class="col-12 col-lg-6") { + div(class="card") { + div(class="card-header d-flex justify-content-between") { + span() { "Expiring soon" } + span(class="badge badge-warn") { (summary.expiring.len()) } + } + ul(class="list-group list-group-flush", dangerously_set_inner_html=expiring_html) {} + } + } + div(class="col-12 col-lg-6") { + div(class="card") { + div(class="card-header d-flex justify-content-between") { + span() { "CRLs due" } + span(class="badge badge-warn") { (summary.crls_due.len()) } + } + ul(class="list-group list-group-flush", dangerously_set_inner_html=due_html) {} + } + } + div(class="col-12") { + div(class="card") { + div(class="card-header d-flex justify-content-between") { + span() { "Recent certificates" } + a(class="small", href=format!("/vaults/{id}/certificates")) { "All certificates" } + } + ul(class="list-group list-group-flush", dangerously_set_inner_html=recent_html) {} + } + } + } + } + }; + + let nav = move || match me.get_clone() { + Some(user) => whoami(&user), + None => view! {}, + }; + + view! { + (app_nav("/", Some(id), nav)) + main(class="container page-shell py-4") { + (toast_error(error)) + (content) + } + } +} + +fn stat_card(label: impl Into, value: i64, href: String) -> View { + let label = label.into(); + view! { + div(class="col-6 col-md-4 col-xl-2") { + a(class="card vault-card text-decoration-none", href=href) { + div(class="card-body text-center") { + div(class="stat-number") { (value) } + div(class="stat-label") { (label) } + } + } + } + } +} + +fn escape(value: &str) -> String { + value.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} diff --git a/web/style.css b/web/style.css new file mode 100644 index 0000000..9674306 --- /dev/null +++ b/web/style.css @@ -0,0 +1,232 @@ +/* App-specific layout for KCA. */ +.app { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.app main { + flex: 1 0 auto; +} + +.app-toast { + position: fixed; + top: 1rem; + left: 50%; + transform: translateX(-50%); + z-index: 1080; + background: var(--danger); + color: #fff; + padding: 0.5rem 1rem; + border-radius: 8px; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35); + max-width: 40rem; +} + +.app-toast.success { + background: #15803d; +} + +.page-shell { + max-width: 1240px; +} + +.avatar { + width: 2rem; + height: 2rem; + border-radius: 50%; + object-fit: cover; + display: block; + flex: 0 0 auto; +} + +.vault-card { + height: 100%; +} + +.stat-number { + font-size: 1.7rem; + font-weight: 700; + line-height: 1.1; +} + +.stat-label { + color: var(--muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.item-name { + font-weight: 600; +} + +.item-sub { + color: var(--muted); + font-size: 0.85rem; + word-break: break-word; +} + +.cert-tree { + list-style: none; + margin: 0; + padding-left: 1.1rem; +} + +.cert-tree > li { + border-left: 1px dashed var(--line); + padding-left: 0.8rem; + margin: 0.15rem 0; +} + +.cert-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding: 0.25rem 0.35rem; + border-radius: 6px; +} + +.cert-row:hover { + background: rgba(94, 234, 212, 0.06); +} + +.cert-row .item-name a { + text-decoration: none; +} + +details.cert-node > summary { + cursor: pointer; + list-style: none; +} + +details.cert-node > summary::-webkit-details-marker { + display: none; +} + +details.cert-node > summary::before { + content: "▸"; + color: var(--accent); + display: inline-block; + width: 1rem; +} + +details.cert-node[open] > summary::before { + content: "▾"; +} + +.tab-pane { + padding-top: 1rem; +} + +.dn-table td, +.dn-table th, +.ext-table td, +.ext-table th { + vertical-align: top; +} + +.fingerprint { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.78rem; + word-break: break-all; +} + +.extension-value { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.78rem; + white-space: pre-wrap; + word-break: break-word; +} + +.form-section { + border: 1px solid var(--line); + border-radius: 10px; + padding: 1rem; + margin-bottom: 1rem; + background: rgba(0, 0, 0, 0.08); +} + +.form-section-title { + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--accent); + margin-bottom: 0.75rem; +} + +.dn-row, +.san-row, +.raw-row { + display: grid; + gap: 0.5rem; + align-items: start; + margin-bottom: 0.5rem; +} + +.dn-row { + grid-template-columns: 12rem 1fr auto; +} + +.san-row { + grid-template-columns: 11rem 1fr auto; +} + +.raw-row { + grid-template-columns: 14rem 5rem 1fr auto; +} + +.extension-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: 0.35rem 1rem; +} + +.wizard-nav { + display: flex; + gap: 0.35rem; + flex-wrap: wrap; + margin-bottom: 1rem; +} + +.wizard-nav button { + min-width: 7rem; +} + +.empty-state { + text-align: center; + color: var(--muted); + padding: 2rem 1rem; +} + +.import-preview { + max-height: 24rem; + overflow: auto; +} + +.table-actions { + white-space: nowrap; +} + +.truncate { + max-width: 28rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.spinner-border.text-warning { + color: var(--accent) !important; +} + +@media (max-width: 576px) { + .dn-row, + .san-row, + .raw-row { + grid-template-columns: 1fr; + } + .truncate { + max-width: 14rem; + } +} diff --git a/web/theme.css b/web/theme.css new file mode 100644 index 0000000..a113a7e --- /dev/null +++ b/web/theme.css @@ -0,0 +1,243 @@ +/* KCA dark PKI-console theme bridged onto Bootstrap 5.3. */ +:root, +[data-bs-theme="dark"] { + --ink: #0f2430; + --ink-dark: #0a1720; + --panel: #132c39; + --panel-light: #1b3b4a; + --line: #2b5668; + --text: #e8f2f4; + --muted: #9db8c2; + --accent: #5eead4; + --accent-bright: #8ff5e4; + --warning: #fbbf24; + --danger: #f87171; + --positive: #4ade80; + + --bs-body-bg: var(--ink-dark); + --bs-body-color: var(--text); + --bs-body-color-rgb: 232, 242, 244; + --bs-body-bg-rgb: 10, 23, 32; + --bs-emphasis-color: #fff; + --bs-secondary-color: var(--muted); + --bs-secondary-bg: var(--panel); + --bs-tertiary-bg: var(--panel); + --bs-border-color: var(--line); + --bs-border-color-translucent: rgba(232, 242, 244, 0.12); + --bs-link-color: var(--accent); + --bs-link-color-rgb: 94, 234, 212; + --bs-link-hover-color: var(--accent-bright); + --bs-link-hover-color-rgb: 143, 245, 228; + --bs-primary: var(--accent); + --bs-primary-rgb: 94, 234, 212; + --bs-warning: var(--warning); + --bs-warning-rgb: 251, 191, 36; + --bs-danger: var(--danger); +} + +body { + min-height: 100vh; + background-color: var(--ink-dark); + background-image: + radial-gradient(ellipse at 50% 0%, rgba(94, 234, 212, 0.08), transparent 55%), + radial-gradient(ellipse at 50% 100%, rgba(0, 0, 0, 0.45), transparent 65%); + background-repeat: no-repeat; + background-attachment: fixed; + color: var(--text); +} + +.navbar { + --bs-navbar-padding-y: 0.7rem; + --bs-navbar-color: var(--muted); + --bs-navbar-hover-color: var(--text); + --bs-navbar-active-color: var(--accent); + --bs-navbar-brand-color: var(--text); + --bs-navbar-brand-hover-color: var(--accent); + border-bottom: 1px solid rgba(0, 0, 0, 0.35); + background: rgb(7, 18, 25); +} + +.navbar .navbar-brand { + font-weight: 700; + letter-spacing: 0.04em; +} + +.brand-glyph { + color: var(--accent); +} + +.card { + --bs-card-bg: rgba(19, 44, 57, 0.78); + border-color: var(--line); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18); +} + +.card-header { + background: rgba(0, 0, 0, 0.16); + border-bottom-color: var(--line); + font-weight: 600; +} + +.table { + --bs-table-bg: transparent; + --bs-table-color: var(--text); + --bs-table-border-color: var(--line); +} + +.table > :not(caption) > * > * { + background-color: transparent; +} + +.btn-primary { + --bs-btn-bg: var(--accent); + --bs-btn-border-color: var(--accent); + --bs-btn-color: #06221f; + --bs-btn-hover-bg: var(--accent-bright); + --bs-btn-hover-border-color: var(--accent-bright); + --bs-btn-hover-color: #06221f; + --bs-btn-active-bg: var(--accent-bright); +} + +.btn-warning { + --bs-btn-bg: var(--warning); + --bs-btn-border-color: var(--warning); + --bs-btn-color: #251a00; +} + +.btn-outline-light { + --bs-btn-color: var(--text); + --bs-btn-border-color: var(--line); + --bs-btn-hover-bg: var(--panel-light); + --bs-btn-hover-border-color: var(--accent); + --bs-btn-hover-color: var(--text); +} + +.form-control, +.form-select { + background-color: rgba(7, 18, 25, 0.65); + border-color: var(--line); + color: var(--text); +} + +.form-control:focus, +.form-select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 0.2rem rgba(94, 234, 212, 0.16); + background-color: rgba(7, 18, 25, 0.8); + color: var(--text); +} + +.form-control::placeholder { + color: #6f8d98; +} + +.form-check-input:checked { + background-color: var(--accent); + border-color: var(--accent); +} + +.nav-tabs { + --bs-nav-tabs-border-color: var(--line); + --bs-nav-tabs-link-active-bg: rgba(19, 44, 57, 0.9); + --bs-nav-tabs-link-active-color: var(--accent); + --bs-nav-tabs-link-active-border-color: var(--line) var(--line) transparent; +} + +.nav-tabs .nav-link { + color: var(--muted); +} + +.nav-tabs .nav-link:hover { + color: var(--text); +} + +.text-secondary, +.muted { + color: var(--muted) !important; +} + +code, +pre, +.mono { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 0.875em; +} + +pre.pem { + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--line); + border-radius: 8px; + padding: 0.75rem; + color: #c9f7ee; + white-space: pre-wrap; + word-break: break-all; + max-height: 22rem; + overflow: auto; +} + +.badge-soft { + background: var(--panel-light); + color: var(--text); + border: 1px solid var(--line); + font-weight: 500; +} + +.badge-critical { + background: rgba(248, 113, 113, 0.18); + color: #fecaca; + border: 1px solid rgba(248, 113, 113, 0.45); +} + +.badge-ca { + background: rgba(94, 234, 212, 0.16); + color: var(--accent); + border: 1px solid rgba(94, 234, 212, 0.4); +} + +.badge-warn { + background: rgba(251, 191, 36, 0.16); + color: #fde68a; + border: 1px solid rgba(251, 191, 36, 0.4); +} + +.badge-danger { + background: rgba(248, 113, 113, 0.16); + color: #fecaca; + border: 1px solid rgba(248, 113, 113, 0.4); +} + +.list-group { + --bs-list-group-bg: transparent; + --bs-list-group-border-color: var(--line); + --bs-list-group-color: var(--text); + --bs-list-group-hover-bg: var(--panel-light); +} + +.modal-content { + background: var(--panel); + border-color: var(--line); +} + +.modal-header, +.modal-footer { + border-color: var(--line); +} + +.alert { + border-color: var(--line); +} + +.alert-danger { + background: rgba(248, 113, 113, 0.12); + color: #fecaca; +} + +.alert-success { + background: rgba(74, 222, 128, 0.12); + color: #bbf7d0; +} + +.alert-info { + background: rgba(94, 234, 212, 0.1); + color: #c9f7ee; +} diff --git a/web/vendor/bootstrap.min.css b/web/vendor/bootstrap.min.css new file mode 100644 index 0000000..3993414 --- /dev/null +++ b/web/vendor/bootstrap.min.css @@ -0,0 +1,6 @@ +@charset "UTF-8";/*! + * Bootstrap v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--bs-link-hover-color:#8bb9fe;--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:#e685b5;--bs-highlight-color:#dee2e6;--bs-highlight-bg:#664d03;--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:#75b798;--bs-form-valid-border-color:#75b798;--bs-form-invalid-color:#ea868f;--bs-form-invalid-border-color:#ea868f}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));text-decoration:underline}a:hover{--bs-link-color-rgb:var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:var(--bs-body-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:var(--bs-secondary-color)}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color-type:initial;--bs-table-bg-type:initial;--bs-table-color-state:initial;--bs-table-bg-state:initial;--bs-table-color:var(--bs-emphasis-color);--bs-table-bg:var(--bs-body-bg);--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-emphasis-color);--bs-table-striped-bg:rgba(var(--bs-emphasis-color-rgb), 0.05);--bs-table-active-color:var(--bs-emphasis-color);--bs-table-active-bg:rgba(var(--bs-emphasis-color-rgb), 0.1);--bs-table-hover-color:var(--bs-emphasis-color);--bs-table-hover-bg:rgba(var(--bs-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state,var(--bs-table-color-type,var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state,var(--bs-table-bg-type,var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(var(--bs-border-width) * 2) solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:var(--bs-border-width) 0}.table-bordered>:not(caption)>*>*{border-width:0 var(--bs-border-width)}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-active{--bs-table-color-state:var(--bs-table-active-color);--bs-table-bg-state:var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state:var(--bs-table-hover-color);--bs-table-bg-state:var(--bs-table-hover-bg)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#a6b5cc;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#b5b6b7;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#a7b9b1;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#a6c3ca;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#ccc2a4;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#c6acae;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#c6c7c8;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#4d5154;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + var(--bs-border-width));padding-bottom:calc(.375rem + var(--bs-border-width));margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + var(--bs-border-width));padding-bottom:calc(.5rem + var(--bs-border-width));font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + var(--bs-border-width));padding-bottom:calc(.25rem + var(--bs-border-width));font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:var(--bs-secondary-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::-moz-placeholder{color:var(--bs-secondary-color);opacity:1}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:var(--bs-secondary-bg)}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:var(--bs-body-color);background-color:transparent;border:solid transparent;border-width:var(--bs-border-width) 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2));padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2))}textarea.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-control-color{width:3rem;height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color::-webkit-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon,none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:var(--bs-secondary-bg)}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 var(--bs-body-color)}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}[data-bs-theme=dark] .form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{--bs-form-check-bg:var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.5rem;padding:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;-webkit-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;-moz-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:var(--bs-secondary-color)}.form-range:disabled::-moz-range-thumb{background-color:var(--bs-secondary-color)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(var(--bs-border-width) * 2));min-height:calc(3.5rem + calc(var(--bs-border-width) * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;height:100%;padding:1rem .75rem;overflow:hidden;text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:var(--bs-border-width) solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::-moz-placeholder,.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:not(:-moz-placeholder-shown),.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:not(:-moz-placeholder-shown)~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control-plaintext~label::after,.form-floating>.form-control:focus~label::after,.form-floating>.form-control:not(:placeholder-shown)~label::after,.form-floating>.form-select~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control:-webkit-autofill~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label{border-width:var(--bs-border-width) 0}.form-floating>.form-control:disabled~label,.form-floating>:disabled~label{color:#6c757d}.form-floating>.form-control:disabled~label::after,.form-floating>:disabled~label::after{background-color:var(--bs-secondary-bg)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);text-align:center;white-space:nowrap;background-color:var(--bs-tertiary-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius)}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(var(--bs-border-width) * -1);border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-valid-color)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-success);border-radius:var(--bs-border-radius)}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:var(--bs-form-valid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:var(--bs-form-valid-border-color)}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:var(--bs-form-valid-border-color)}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:var(--bs-form-valid-color)}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:var(--bs-form-valid-color)}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-invalid-color)}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-danger);border-radius:var(--bs-border-radius)}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:var(--bs-form-invalid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:var(--bs-form-invalid-border-color)}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:var(--bs-form-invalid-border-color)}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:var(--bs-form-invalid-color)}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:var(--bs-form-invalid-color)}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:var(--bs-body-color);--bs-btn-bg:transparent;--bs-btn-border-width:var(--bs-border-width);--bs-btn-border-color:transparent;--bs-btn-border-radius:var(--bs-border-radius);--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked:focus-visible+.btn{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:0 0 0 #000;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:var(--bs-border-radius-lg)}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:var(--bs-border-radius-sm)}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:var(--bs-body-color);--bs-dropdown-bg:var(--bs-body-bg);--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:var(--bs-border-radius);--bs-dropdown-border-width:var(--bs-border-width);--bs-dropdown-inner-border-radius:calc(var(--bs-border-radius) - var(--bs-border-width));--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:var(--bs-box-shadow);--bs-dropdown-link-color:var(--bs-body-color);--bs-dropdown-link-hover-color:var(--bs-body-color);--bs-dropdown-link-hover-bg:var(--bs-tertiary-bg);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:var(--bs-tertiary-color);--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0;border-radius:var(--bs-dropdown-item-border-radius,0)}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:var(--bs-border-radius)}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:calc(var(--bs-border-width) * -1)}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:calc(var(--bs-border-width) * -1)}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;background:0 0;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:var(--bs-border-width);--bs-nav-tabs-border-color:var(--bs-border-color);--bs-nav-tabs-border-radius:var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color:var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color:var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg:var(--bs-body-bg);--bs-nav-tabs-link-active-border-color:var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:var(--bs-border-radius);--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap:1rem;--bs-nav-underline-border-width:0.125rem;--bs-nav-underline-link-active-color:var(--bs-emphasis-color);gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid transparent}.nav-underline .nav-link:focus,.nav-underline .nav-link:hover{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color:rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color:rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius:var(--bs-border-radius);--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon{--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width:var(--bs-border-width);--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:var(--bs-body-bg);--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:var(--bs-body-color);--bs-accordion-bg:var(--bs-body-bg);--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:var(--bs-border-width);--bs-accordion-border-radius:var(--bs-border-radius);--bs-accordion-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:var(--bs-body-color);--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:var(--bs-primary-text-emphasis);--bs-accordion-active-bg:var(--bs-primary-bg-subtle)}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type>.accordion-header .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type>.accordion-header .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type>.accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush>.accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush>.accordion-item:first-child{border-top:0}.accordion-flush>.accordion-item:last-child{border-bottom:0}.accordion-flush>.accordion-item>.accordion-header .accordion-button,.accordion-flush>.accordion-item>.accordion-header .accordion-button.collapsed{border-radius:0}.accordion-flush>.accordion-item>.accordion-collapse{border-radius:0}[data-bs-theme=dark] .accordion-button::after{--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:var(--bs-secondary-color);--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:var(--bs-body-bg);--bs-pagination-border-width:var(--bs-border-width);--bs-pagination-border-color:var(--bs-border-color);--bs-pagination-border-radius:var(--bs-border-radius);--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:var(--bs-tertiary-bg);--bs-pagination-hover-border-color:var(--bs-border-color);--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:var(--bs-secondary-bg);--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:var(--bs-secondary-color);--bs-pagination-disabled-bg:var(--bs-secondary-bg);--bs-pagination-disabled-border-color:var(--bs-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(var(--bs-border-width) * -1)}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:var(--bs-border-radius-lg)}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:var(--bs-border-radius-sm)}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:var(--bs-border-radius);display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:var(--bs-border-width) solid var(--bs-alert-border-color);--bs-alert-border-radius:var(--bs-border-radius);--bs-alert-link-color:inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:var(--bs-primary-text-emphasis);--bs-alert-bg:var(--bs-primary-bg-subtle);--bs-alert-border-color:var(--bs-primary-border-subtle);--bs-alert-link-color:var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color:var(--bs-secondary-text-emphasis);--bs-alert-bg:var(--bs-secondary-bg-subtle);--bs-alert-border-color:var(--bs-secondary-border-subtle);--bs-alert-link-color:var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color:var(--bs-success-text-emphasis);--bs-alert-bg:var(--bs-success-bg-subtle);--bs-alert-border-color:var(--bs-success-border-subtle);--bs-alert-link-color:var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color:var(--bs-info-text-emphasis);--bs-alert-bg:var(--bs-info-bg-subtle);--bs-alert-border-color:var(--bs-info-border-subtle);--bs-alert-link-color:var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color:var(--bs-warning-text-emphasis);--bs-alert-bg:var(--bs-warning-bg-subtle);--bs-alert-border-color:var(--bs-warning-border-subtle);--bs-alert-link-color:var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color:var(--bs-danger-text-emphasis);--bs-alert-bg:var(--bs-danger-bg-subtle);--bs-alert-border-color:var(--bs-danger-border-subtle);--bs-alert-link-color:var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color:var(--bs-light-text-emphasis);--bs-alert-bg:var(--bs-light-bg-subtle);--bs-alert-border-color:var(--bs-light-border-subtle);--bs-alert-link-color:var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color:var(--bs-dark-text-emphasis);--bs-alert-bg:var(--bs-dark-bg-subtle);--bs-alert-border-color:var(--bs-dark-border-subtle);--bs-alert-link-color:var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress,.progress-stacked{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:var(--bs-secondary-bg);--bs-progress-border-radius:var(--bs-border-radius);--bs-progress-box-shadow:var(--bs-box-shadow-inset);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:var(--bs-body-color);--bs-list-group-bg:var(--bs-body-bg);--bs-list-group-border-color:var(--bs-border-color);--bs-list-group-border-width:var(--bs-border-width);--bs-list-group-border-radius:var(--bs-border-radius);--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:var(--bs-secondary-color);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-tertiary-bg);--bs-list-group-action-active-color:var(--bs-body-color);--bs-list-group-action-active-bg:var(--bs-secondary-bg);--bs-list-group-disabled-color:var(--bs-secondary-color);--bs-list-group-disabled-bg:var(--bs-body-bg);--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{--bs-list-group-color:var(--bs-primary-text-emphasis);--bs-list-group-bg:var(--bs-primary-bg-subtle);--bs-list-group-border-color:var(--bs-primary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-primary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-primary-border-subtle);--bs-list-group-active-color:var(--bs-primary-bg-subtle);--bs-list-group-active-bg:var(--bs-primary-text-emphasis);--bs-list-group-active-border-color:var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color:var(--bs-secondary-text-emphasis);--bs-list-group-bg:var(--bs-secondary-bg-subtle);--bs-list-group-border-color:var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-secondary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-secondary-border-subtle);--bs-list-group-active-color:var(--bs-secondary-bg-subtle);--bs-list-group-active-bg:var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color:var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color:var(--bs-success-text-emphasis);--bs-list-group-bg:var(--bs-success-bg-subtle);--bs-list-group-border-color:var(--bs-success-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-success-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-success-border-subtle);--bs-list-group-active-color:var(--bs-success-bg-subtle);--bs-list-group-active-bg:var(--bs-success-text-emphasis);--bs-list-group-active-border-color:var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color:var(--bs-info-text-emphasis);--bs-list-group-bg:var(--bs-info-bg-subtle);--bs-list-group-border-color:var(--bs-info-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-info-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-info-border-subtle);--bs-list-group-active-color:var(--bs-info-bg-subtle);--bs-list-group-active-bg:var(--bs-info-text-emphasis);--bs-list-group-active-border-color:var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color:var(--bs-warning-text-emphasis);--bs-list-group-bg:var(--bs-warning-bg-subtle);--bs-list-group-border-color:var(--bs-warning-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-warning-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-warning-border-subtle);--bs-list-group-active-color:var(--bs-warning-bg-subtle);--bs-list-group-active-bg:var(--bs-warning-text-emphasis);--bs-list-group-active-border-color:var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color:var(--bs-danger-text-emphasis);--bs-list-group-bg:var(--bs-danger-bg-subtle);--bs-list-group-border-color:var(--bs-danger-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-danger-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-danger-border-subtle);--bs-list-group-active-color:var(--bs-danger-bg-subtle);--bs-list-group-active-bg:var(--bs-danger-text-emphasis);--bs-list-group-active-border-color:var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color:var(--bs-light-text-emphasis);--bs-list-group-bg:var(--bs-light-bg-subtle);--bs-list-group-border-color:var(--bs-light-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-light-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-light-border-subtle);--bs-list-group-active-color:var(--bs-light-bg-subtle);--bs-list-group-active-bg:var(--bs-light-text-emphasis);--bs-list-group-active-border-color:var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color:var(--bs-dark-text-emphasis);--bs-list-group-bg:var(--bs-dark-bg-subtle);--bs-list-group-border-color:var(--bs-dark-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-dark-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-dark-border-subtle);--bs-list-group-active-color:var(--bs-dark-bg-subtle);--bs-list-group-active-bg:var(--bs-dark-text-emphasis);--bs-list-group-active-border-color:var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color:#000;--bs-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");--bs-btn-close-opacity:0.5;--bs-btn-close-hover-opacity:0.75;--bs-btn-close-focus-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-btn-close-focus-opacity:1;--bs-btn-close-disabled-opacity:0.25;--bs-btn-close-white-filter:invert(1) grayscale(100%) brightness(200%);box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{filter:var(--bs-btn-close-white-filter)}[data-bs-theme=dark] .btn-close{filter:var(--bs-btn-close-white-filter)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-border-width:var(--bs-border-width);--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:var(--bs-border-radius);--bs-toast-box-shadow:var(--bs-box-shadow);--bs-toast-header-color:var(--bs-secondary-color);--bs-toast-header-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-header-border-color:var(--bs-border-color-translucent);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color: ;--bs-modal-bg:var(--bs-body-bg);--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:var(--bs-border-width);--bs-modal-border-radius:var(--bs-border-radius-lg);--bs-modal-box-shadow:var(--bs-box-shadow-sm);--bs-modal-inner-border-radius:calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:var(--bs-border-width);--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:var(--bs-body-bg);--bs-tooltip-bg:var(--bs-emphasis-color);--bs-tooltip-border-radius:var(--bs-border-radius);--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:var(--bs-body-bg);--bs-popover-border-width:var(--bs-border-width);--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:var(--bs-border-radius-lg);--bs-popover-inner-border-radius:calc(var(--bs-border-radius-lg) - var(--bs-border-width));--bs-popover-box-shadow:var(--bs-box-shadow);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color:inherit;--bs-popover-header-bg:var(--bs-secondary-bg);--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:var(--bs-body-color);--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}[data-bs-theme=dark] .carousel .carousel-control-next-icon,[data-bs-theme=dark] .carousel .carousel-control-prev-icon,[data-bs-theme=dark].carousel .carousel-control-next-icon,[data-bs-theme=dark].carousel .carousel-control-prev-icon{filter:invert(1) grayscale(100)}[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target],[data-bs-theme=dark].carousel .carousel-indicators [data-bs-target]{background-color:#000}[data-bs-theme=dark] .carousel .carousel-caption,[data-bs-theme=dark].carousel .carousel-caption{color:#000}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color:var(--bs-body-color);--bs-offcanvas-bg:var(--bs-body-bg);--bs-offcanvas-border-width:var(--bs-border-width);--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:var(--bs-box-shadow-sm);--bs-offcanvas-transition:transform 0.3s ease-in-out;--bs-offcanvas-title-line-height:1.5}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin:calc(-.5 * var(--bs-offcanvas-padding-y)) calc(-.5 * var(--bs-offcanvas-padding-x)) calc(-.5 * var(--bs-offcanvas-padding-y)) auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(var(--bs-primary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(var(--bs-secondary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(var(--bs-success-rgb),var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(var(--bs-info-rgb),var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(var(--bs-warning-rgb),var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(var(--bs-danger-rgb),var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(var(--bs-light-rgb),var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(var(--bs-dark-rgb),var(--bs-bg-opacity,1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important}.link-primary:focus,.link-primary:hover{color:RGBA(10,88,202,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,94,100,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important}.link-success:focus,.link-success:hover{color:RGBA(20,108,67,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important}.link-info:focus,.link-info:hover{color:RGBA(61,213,243,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important}.link-warning:focus,.link-warning:hover{color:RGBA(255,205,57,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important}.link-danger:focus,.link-danger:hover{color:RGBA(176,42,55,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important}.link-light:focus,.link-light:hover{color:RGBA(249,250,251,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important}.link-dark:focus,.link-dark:hover{color:RGBA(26,30,33,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-body-emphasis:focus,.link-body-emphasis:hover{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,.75))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x,0) var(--bs-focus-ring-y,0) var(--bs-focus-ring-blur,0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-underline-offset:0.25em;-webkit-backface-visibility:hidden;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion:reduce){.icon-link>.bi{transition:none}}.icon-link-hover:focus-visible>.bi,.icon-link-hover:hover>.bi{transform:var(--bs-icon-link-transform,translate3d(.25em,0,0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--bs-border-width);min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.shadow-sm{box-shadow:var(--bs-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--bs-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color:rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color:rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color:rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color:rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color:rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color:rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color:rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color:rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity:1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity:1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity:1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10{--bs-link-opacity:0.1}.link-opacity-10-hover:hover{--bs-link-opacity:0.1}.link-opacity-25{--bs-link-opacity:0.25}.link-opacity-25-hover:hover{--bs-link-opacity:0.25}.link-opacity-50{--bs-link-opacity:0.5}.link-opacity-50-hover:hover{--bs-link-opacity:0.5}.link-opacity-75{--bs-link-opacity:0.75}.link-opacity-75-hover:hover{--bs-link-opacity:0.75}.link-opacity-100{--bs-link-opacity:1}.link-opacity-100-hover:hover{--bs-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-underline-opacity-0{--bs-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity:0}.link-underline-opacity-10{--bs-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity:0.1}.link-underline-opacity-25{--bs-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity:0.25}.link-underline-opacity-50{--bs-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity:0.5}.link-underline-opacity-75{--bs-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity:0.75}.link-underline-opacity-100{--bs-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity:1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} +/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file