From d1ed0a4e2ef66ed41d03db6b5252508e3cc75d0b Mon Sep 17 00:00:00 2001 From: Yassine Rhouma Date: Fri, 24 Jul 2026 11:08:21 +0200 Subject: [PATCH 01/22] feat(vc): add new service skeleton --- .gitignore | 4 + docs/spec/dva-vc-manager.yaml | 417 ++++++++++ dva-vc-manager/Dockerfile | 52 ++ dva-vc-manager/README.md | 60 ++ dva-vc-manager/pyproject.toml | 35 + dva-vc-manager/src/dva_vc_manager/__init__.py | 19 + dva-vc-manager/src/dva_vc_manager/auth.py | 20 + dva-vc-manager/src/dva_vc_manager/config.py | 57 ++ .../src/dva_vc_manager/dependencies.py | 34 + dva-vc-manager/src/dva_vc_manager/did_key.py | 58 ++ dva-vc-manager/src/dva_vc_manager/keys.py | 110 +++ dva-vc-manager/src/dva_vc_manager/main.py | 129 +++ dva-vc-manager/src/dva_vc_manager/models.py | 117 +++ dva-vc-manager/src/dva_vc_manager/routes.py | 198 +++++ dva-vc-manager/src/dva_vc_manager/signing.py | 176 ++++ .../src/dva_vc_manager/whitelist.py | 131 +++ dva-vc-manager/tests/__init__.py | 0 dva-vc-manager/tests/test_did_key.py | 40 + dva-vc-manager/tests/test_jws.py | 84 ++ dva-vc-manager/tests/test_keys.py | 48 ++ dva-vc-manager/tests/test_routes.py | 156 ++++ dva-vc-manager/tests/test_whitelist.py | 61 ++ dva-vc-manager/uv.lock | 756 ++++++++++++++++++ 23 files changed, 2762 insertions(+) create mode 100644 docs/spec/dva-vc-manager.yaml create mode 100644 dva-vc-manager/Dockerfile create mode 100644 dva-vc-manager/README.md create mode 100644 dva-vc-manager/pyproject.toml create mode 100644 dva-vc-manager/src/dva_vc_manager/__init__.py create mode 100644 dva-vc-manager/src/dva_vc_manager/auth.py create mode 100644 dva-vc-manager/src/dva_vc_manager/config.py create mode 100644 dva-vc-manager/src/dva_vc_manager/dependencies.py create mode 100644 dva-vc-manager/src/dva_vc_manager/did_key.py create mode 100644 dva-vc-manager/src/dva_vc_manager/keys.py create mode 100644 dva-vc-manager/src/dva_vc_manager/main.py create mode 100644 dva-vc-manager/src/dva_vc_manager/models.py create mode 100644 dva-vc-manager/src/dva_vc_manager/routes.py create mode 100644 dva-vc-manager/src/dva_vc_manager/signing.py create mode 100644 dva-vc-manager/src/dva_vc_manager/whitelist.py create mode 100644 dva-vc-manager/tests/__init__.py create mode 100644 dva-vc-manager/tests/test_did_key.py create mode 100644 dva-vc-manager/tests/test_jws.py create mode 100644 dva-vc-manager/tests/test_keys.py create mode 100644 dva-vc-manager/tests/test_routes.py create mode 100644 dva-vc-manager/tests/test_whitelist.py create mode 100644 dva-vc-manager/uv.lock diff --git a/.gitignore b/.gitignore index 180562b3..aa88ca50 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ # commitlint node_modules/ +__pycache__/ +*.pyc +.DS_Store +*.egg-info/ diff --git a/docs/spec/dva-vc-manager.yaml b/docs/spec/dva-vc-manager.yaml new file mode 100644 index 00000000..b72ab016 --- /dev/null +++ b/docs/spec/dva-vc-manager.yaml @@ -0,0 +1,417 @@ +openapi: 3.1.0 +info: + title: DVA VC Manager + version: 0.3.0 + description: |- + Issues and verifies Attestations of Veracity (AoV) as W3C VC 2.0 JSON + Web Signatures (Ed25519/EdDSA via PyNaCl). + + Verification is fail-closed: a JWS is only accepted when its issuer + did:key is present in the local whitelist. An empty whitelist rejects + every verification; an untrusted issuer is rejected. + + Wire casing: AoV routes use camelCase to match the DVA API orchestrator; + admin routes use snake_case. + contact: + email: bpeter@edu.bme.hu +servers: + - url: http://localhost:8001 + description: Provider + - url: http://localhost:8002 + description: Consumer +tags: + - name: AoV Issue + description: Issue an AoV JWS credential (Ed25519/EdDSA via PyNaCl). Open — no auth. + - name: AoV Verify + description: Verify an AoV JWS credential against the fail-closed did:key whitelist. Open — no auth. + - name: Admin + description: Manage the did:key whitelist and inspect this issuer's own signing key. Requires a bearer API key. +paths: + /aov/issue: + post: + tags: [AoV Issue] + summary: Issue an AoV JWS credential + description: | + Signs the supplied AoV payload as a W3C VC 2.0 JSON-LD JWS using the + service's persisted Ed25519 private key (EdDSA). The returned `jws` + is a compact `header.payload.signature` string. A UUID for the + credential is generated server-side and embedded in the JWS payload. + operationId: issueAov + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AovIssueRequest' + examples: + NettingSettlement: + summary: Issue an AoV for a netting settlement evaluation + value: + validSince: '2026-07-15T10:00:00Z' + subject: did:web:provider.example.com + issuerId: did:web:intermediary.example.com + recordId: rec-0001 + contractId: contract-0001 + dataExchangeId: xchg-0001 + payload: '{"netAmount":-100,"bankID":"BANK_A"}' + evaluationResults: + - engine: JQ + timestamp: '2026-07-15T10:00:01Z' + success: true + details: zero-sum satisfied + responses: + '200': + description: The signed JWS. + content: + application/json: + schema: + $ref: '#/components/schemas/AovIssueResponse' + '422': + description: Malformed request body or missing required fields. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /aov/verify: + post: + tags: [AoV Verify] + summary: Verify an AoV JWS credential + description: | + Verifies the Ed25519 signature of a compact AoV JWS. The issuer + did:key is extracted from the JWS payload and looked up in the + local whitelist. Fail-closed: when the whitelist is empty or the + issuer is not registered, `verified` is false with a descriptive + `reason`. A malformed JWS returns 400. + operationId: verifyAov + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AovVerifyRequest' + examples: + VerifyJws: + summary: Verify a previously issued AoV JWS + value: + jws: 'eyJhbGciOiJFZERTQSIsInR5cCI6IlZDK0xELUpTT04rSldTIn0...' + responses: + '200': + description: Verification outcome. + content: + application/json: + schema: + $ref: '#/components/schemas/AovVerifyResponse' + examples: + Verified: + summary: Signature valid and issuer whitelisted + value: + verified: true + Rejected: + summary: Issuer not in whitelist + value: + verified: false + reason: issuer not whitelisted + '400': + description: Malformed JWS (not a valid 3-part compact JWS). + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /admin/whitelist: + get: + tags: [Admin] + summary: List all whitelisted did:keys + description: | + Returns the current did:key whitelist as an array. + + **Wire casing: snake_case** (`id`, `did_key`, `label`). + operationId: listWhitelist + responses: + '200': + description: Array of whitelist entries. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/WhitelistEntry' + '401': + description: Missing or invalid bearer API key. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + tags: [Admin] + summary: Add a did:key to the whitelist + description: | + Registers a new trusted attester `did:key`. Returns `201` with the + created entry on success. Duplicate `did_key` values return `400`. + + **Wire casing: snake_case** (`did_key`, `label`). + operationId: addWhitelistEntry + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WhitelistAddRequest' + examples: + AddIssuer: + summary: Whitelist an issuer did:key + value: + did_key: did:key:z6MktRz8iVwNh1rLKV47C2i2nMe4zwGt7SgLBjS9zw1jNuQY + label: DVA VC Manager (provider) + responses: + '201': + description: Entry created. + content: + application/json: + schema: + $ref: '#/components/schemas/WhitelistEntry' + '400': + description: Invalid did:key or duplicate entry. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '401': + description: Missing or invalid bearer API key. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /admin/whitelist/{did_key}: + delete: + tags: [Admin] + summary: Remove a did:key from the whitelist + description: | + Deletes the whitelist entry whose `did_key` matches the URL-encoded + path parameter. Returns `204` on success, `404` if not found. + + The `{did_key}` path parameter is the percent-encoded `did:key:...` + identifier — colon (`:`) must be encoded as `%3A` per RFC 3986. + operationId: removeWhitelistEntry + parameters: + - name: did_key + in: path + required: true + description: URL-encoded did:key identifier to remove (e.g. `did%3Akey%3Az6Mk...`). + schema: + type: string + example: did%3Akey%3Az6Mku8XYifPt5tfL93VpJhFWuoyQDt6bTRqfWestrpo6YM5d + responses: + '204': + description: Entry removed. + '401': + description: Missing or invalid bearer API key. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: did:key not in whitelist. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /admin/keys: + get: + tags: [Admin] + summary: Get this issuer's own did:key + description: | + Returns the `did:key` identifier derived from the service's persisted + Ed25519 signing key. Read-only — no private bytes are ever exposed. + + **Wire casing: snake_case** (`issuer_did_key`, `key_path`). + operationId: getOwnKey + responses: + '200': + description: Issuer's own did:key and persisted key location. + content: + application/json: + schema: + $ref: '#/components/schemas/OwnKey' + '401': + description: Missing or invalid bearer API key. + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + securitySchemes: + ApiKey: + type: apiKey + in: header + name: Authorization + description: |- + Bearer API key. Send as `Authorization: Bearer `. + The service fails closed (`401`) for all `/admin/*` routes when the + `DVA_VC_MANAGER_API_KEY` environment variable is unset. + schemas: + QualityEngine: + type: string + enum: [SCHEMA, GREAT_EXPECTATIONS, JQ] + description: The evaluation engine that produced a veracity result. + EvaluationResult: + type: object + description: |- + Outcome of one VLA requirement evaluation, embedded in `AovIssueRequest. + evaluationResults`. + + **Wire casing: camelCase** (`engine`, `timestamp`, `success`, + `details`, `error`). + required: [timestamp, success] + additionalProperties: false + properties: + engine: + $ref: '#/components/schemas/QualityEngine' + timestamp: + type: string + format: date-time + description: RFC 3339 timestamp marking when the evaluation ran. + success: + type: boolean + description: Whether the evaluation passed. + details: + type: string + nullable: true + description: Human-readable success explanation when `success` is true. + error: + type: string + nullable: true + description: Human-readable failure explanation when `success` is false. + example: + engine: JQ + timestamp: '2026-01-31T17:48:10.904264Z' + success: true + details: Actor name is correct + AovIssueRequest: + type: object + description: Body of POST /aov/issue. Wire casing: camelCase. + additionalProperties: false + required: [validSince, subject, issuerId, recordId, contractId, dataExchangeId, payload, evaluationResults] + properties: + validSince: + type: string + format: date-time + description: Earliest moment from which the AoV is considered valid. + subject: + type: string + description: The credential subject's identifier (e.g. a did:web). + issuerId: + type: string + description: The logical issuer identifier (separate from the did:key). + recordId: + type: string + description: External record identifier being attested. + contractId: + type: string + description: The data-contract identifier this AoV covers. + dataExchangeId: + type: string + description: The data-exchange identifier this AoV covers. + payload: + type: string + description: The opaque payload (stringified JSON) to sign as the JWS payload. + evaluationResults: + type: array + items: + $ref: '#/components/schemas/EvaluationResult' + description: The VLA evaluation results carried inside the AoV. + AovIssueResponse: + type: object + description: Response of POST /aov/issue. Wire casing: camelCase. + additionalProperties: false + required: [jws] + properties: + jws: + type: string + description: Compact JWS header.payload.signature (Ed25519/EdDSA). + AovVerifyRequest: + type: object + description: Body of POST /aov/verify. Wire casing: camelCase. + additionalProperties: false + required: [jws] + properties: + jws: + type: string + description: Compact JWS to verify (header.payload.signature). + AovVerifyResponse: + type: object + description: Response of POST /aov/verify. + additionalProperties: false + required: [verified] + properties: + verified: + type: boolean + description: Whether the JWS is well-formed, correctly signed, and the issuer is whitelisted. + reason: + type: string + nullable: true + description: Present when verified is false. Explains why verification failed. + WhitelistEntry: + type: object + description: |- + A single whitelist entry. **Wire casing: snake_case** (`id`, `did_key`, + `label`). + additionalProperties: false + required: [id, did_key] + properties: + id: + type: string + format: uuid + description: Internal UUID assigned to this whitelist entry. + did_key: + type: string + description: The attester's `did:key` identifier (e.g. `did:key:z6Mk...`). + label: + type: string + nullable: true + description: Optional human-readable label for the attester. + WhitelistAddRequest: + type: object + description: |- + Body of `POST /admin/whitelist`. **Wire casing: snake_case** + (`did_key`, `label`). + additionalProperties: false + required: [did_key] + properties: + did_key: + type: string + description: The did:key identifier to whitelist. + label: + type: string + nullable: true + description: Optional human-readable label. + OwnKey: + type: object + description: |- + This issuer's own did:key plus the persisted private-key location. + **Wire casing: snake_case** (`issuer_did_key`, `key_path`). Private + bytes are never exposed. + additionalProperties: false + required: [issuer_did_key, key_path] + properties: + issuer_did_key: + type: string + description: The did:key identifier derived from this service's Ed25519 public key. + key_path: + type: string + description: Filesystem path where the signing key is persisted (no private bytes). + Error: + type: object + description: RFC 7807-style problem object emitted on all error responses. + additionalProperties: false + required: [type, title] + properties: + type: + type: string + description: A URI reference identifying the problem type. + title: + type: string + description: Short human-readable summary of the problem. + detail: + type: string + nullable: true + description: Longer human-readable explanation specific to this occurrence. \ No newline at end of file diff --git a/dva-vc-manager/Dockerfile b/dva-vc-manager/Dockerfile new file mode 100644 index 00000000..64d2df0f --- /dev/null +++ b/dva-vc-manager/Dockerfile @@ -0,0 +1,52 @@ +FROM python:3.12-slim AS build + +# Install uv (mirrors dva-processing/Dockerfile style) +COPY --from=ghcr.io/astral-sh/uv:0.7 /uv /uvx /bin/ + +WORKDIR /app/ + +# Install dependencies (cached layer) +RUN \ + --mount=type=cache,target=/root/.cache/uv2 \ + --mount=type=bind,source=./dva-vc-manager/pyproject.toml,target=pyproject.toml \ + uv pip install --system \ + "fastapi~=0.136.3" \ + "asyncpg>=0.30.0" \ + "base58>=2.1.1" \ + "pydantic>=2.10.6" \ + "pydantic-settings>=2.5.0" \ + "pynacl>=1.5.0" \ + "structlog>=25.1.0" \ + "uvicorn>=0.34.3" + +# Copy app files +COPY ./dva-vc-manager/ /app/ + +# ---------------------------------------------------------------- +FROM python:3.12-slim + +# netcat for healthcheck +RUN apt-get update && \ + apt-get install -y --no-install-recommends netcat-openbsd=1.* \ + && rm -rf /var/lib/apt/lists/ + +# uvicorn binary +COPY --from=build /usr/local/bin/uvicorn /usr/local/bin/uvicorn + +# Copy installed packages + source from build stage +COPY --from=build --chown=app:app /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages +COPY --from=build --chown=app:app /app/src /app/src +COPY --from=build --chown=app:app /app/pyproject.toml /app/pyproject.toml + +WORKDIR /app + +ENV PYTHONPATH=/app/src \ + PYTHONUNBUFFERED=1 + +# Hand-written OpenAPI spec — served at /swagger/openapi.yaml +COPY ./docs/spec/dva-vc-manager.yaml /app/openapi.yaml +ENV DVA_VC_MANAGER_OPENAPI_FILE=/app/openapi.yaml + +ENTRYPOINT ["uvicorn", "dva_vc_manager.main:app", "--host", "0.0.0.0", "--port", "8000"] + +EXPOSE 8000/tcp \ No newline at end of file diff --git a/dva-vc-manager/README.md b/dva-vc-manager/README.md new file mode 100644 index 00000000..cbbfbb85 --- /dev/null +++ b/dva-vc-manager/README.md @@ -0,0 +1,60 @@ +# DVA VC Manager + +DVA VC Manager is a FastAPI service hosted at each **Participant** that owns the +Attestation-of-Veracity credential lifecycle: + +* **Issues** AoV credentials as W3C VC 2.0 JSON-LD JWS (Ed25519/EdDSA) at the provider side. +* **Verifies** AoV JWS credentials at the consumer side against a local ``did:key`` whitelist. + +## Why + +In the refactored DVA topology this is the only component that performs cryptographic signing +or verification of credentials. Stepping the JWS issuance out of ``dva-api`` lets the API +shrink to pure orchestration while reusing a single, audited crypto library (**PyNaCl** / +libsodium — no hand-rolled cryptography anywhere). + +## Role + +| Endpoint | Persona | Purpose | +|---|---|---| +| ``POST /aov/issue`` | DVA API (provider side) | Credential issuance — sign the AoV JWS during the synchronous attestation flow | +| ``POST /aov/verify`` | DVA API (consumer side) | Verify a JWS against the attester whitelist (fail-closed) | +| ``GET /admin/whitelist`` | Operator | List trusted attester ``did:key`` identifiers | +| ``POST /admin/whitelist`` | Operator | Register a trusted attester ``did:key`` | +| ``DELETE /admin/whitelist/{did_key}`` | Operator | Remove a trusted attester | +| ``GET /admin/keys`` | Operator | View this service's own issuer ``did:key`` (read-only) | + +All ``/admin/*`` endpoints require ``Authorization: Bearer ${DVA_VC_MANAGER_API_KEY}`` and +fail-closed with ``401`` when the key is empty. + +## Cryptography libraries + +| Concern | Library | Reason | +|---|---|---| +| Ed25519 sign/verify | ``PyNaCl`` (libsodium binding) | Canonical, audited, no hand-rolled crypto | +| ``did:key`` codec | ``base58`` (PyPI) + multicodec prefix | Tiny audited helper; matches W3C spec | +| Key persistence | ``base64`` from stdlib | Simple ``base64(priv_seed)\|base64(pub)`` format | + +The JWS payload shape (``@context``, ``type``, ``issuer``, ``validFrom``, ``credentialSubject``) +is **byte-identical** with the prior Kotlin ``JwsSigner.kt:39-57`` so any existing JWS consumer +(PDC, other DVAs, downstream wallets) can verify a Python-issued credential with the Kotlin +verifier and vice-versa. + +## Run locally (dev) + +```bash +cd data-veracity-main/dva-vc-manager +uv sync +uv run pytest # tests (FakeWhitelist + temp signing key, no Postgres needed) +uv run dva-vc-manager # boot the service on :8000 +``` + +## Configuration (.env) + +| Var | Default | Purpose | +|---|---|---| +| ``DVA_VC_MANAGER_SIGNING_KEY_PATH`` | ``/data/dva-vc-signing-key.pem`` | Ed25519 key file path (created 0600 on first boot) | +| ``DVA_VC_MANAGER_DB_URL`` | *(empty)* | Postgres DSN for the whitelist. Empty → in-memory ``FakeWhitelist`` (verify path fails-closed until admin populates it). | +| ``DVA_VC_MANAGER_API_KEY`` | *(empty)* | Shared-secret bearer for ``/admin/*``. When empty, admin endpoints are disabled. | +| ``DVA_VC_MANAGER_PORT`` | ``8000`` | Listen port | +| ``DVA_VC_MANAGER_LOG_LEVEL`` | ``INFO`` | Standard Python log-level name | \ No newline at end of file diff --git a/dva-vc-manager/pyproject.toml b/dva-vc-manager/pyproject.toml new file mode 100644 index 00000000..c2a2f1ee --- /dev/null +++ b/dva-vc-manager/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "dva-vc-manager" +version = "0.1.0" +description = "DVA Verifiable Credential Manager — issues and verifies AoV JWS credentials" +readme = "README.md" +authors = [{ name = "FTSRG", email = "bpeter@edu.bme.hu" }] +license = "Apache-2.0" +requires-python = ">=3.10" +dependencies = [ + "asyncpg>=0.30.0", + "base58>=2.1.1", + "fastapi~=0.136.3", + "pydantic>=2.10.6", + "pydantic-settings>=2.5.0", + "pynacl>=1.5.0", + "structlog>=25.1.0", + "uvicorn>=0.34.3", +] +classifiers = ["Private :: Do Not Upload"] + +[project.scripts] +dva-vc-manager = "dva_vc_manager.main:cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/dva_vc_manager"] + +[dependency-groups] +dev = ["pytest>=8.3.5", "httpx>=0.27.0", "pytest-asyncio>=0.24.0"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" \ No newline at end of file diff --git a/dva-vc-manager/src/dva_vc_manager/__init__.py b/dva-vc-manager/src/dva_vc_manager/__init__.py new file mode 100644 index 00000000..680b7fca --- /dev/null +++ b/dva-vc-manager/src/dva_vc_manager/__init__.py @@ -0,0 +1,19 @@ +"""DVA Verifiable Credential Manager. + +Hosted at each Participant. Owns: + +* The Ed25519 signing key for that participant (loaded from a file or + generated on first boot via PyNaCl — never a hand-rolled crypto + primitive). +* The ``did:key`` whitelist of trusted attesters (used by the verify + side). +* The W3C VC 2.0 JSON-LD payload shape used for the Attestation of + Veracity (AoV) — produced and consumed verbatim, never mutated. + +The service exposes ``POST /aov/issue`` (called by the DVA API during +credential issuance in the synchronous attestation flow) and +``POST /aov/verify`` (called by the DVA API during the consumer-side +verification flow). +""" + +__version__ = "0.1.0" \ No newline at end of file diff --git a/dva-vc-manager/src/dva_vc_manager/auth.py b/dva-vc-manager/src/dva_vc_manager/auth.py new file mode 100644 index 00000000..733aee24 --- /dev/null +++ b/dva-vc-manager/src/dva_vc_manager/auth.py @@ -0,0 +1,20 @@ +"""Minimal Bearer-token auth for the admin endpoints. + +Mirrors the inline guard the Kotlin ``route/adminRoutes.kt`` uses: when +``DVA_VC_MANAGER_API_KEY`` is empty, admin endpoints are disabled +entirely; otherwise require ``Authorization: Bearer ``. +""" + +from __future__ import annotations + +from fastapi import Header, HTTPException, status + +from .config import cfg + + +def require_api_key(authorization: str | None = Header(default=None)) -> None: + if cfg.api_key == "": + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "API key not configured") + header = (authorization or "").removeprefix("Bearer ").strip() + if header != cfg.api_key: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid API key") \ No newline at end of file diff --git a/dva-vc-manager/src/dva_vc_manager/config.py b/dva-vc-manager/src/dva_vc_manager/config.py new file mode 100644 index 00000000..35e6a332 --- /dev/null +++ b/dva-vc-manager/src/dva_vc_manager/config.py @@ -0,0 +1,57 @@ +"""Runtime configuration for the DVA VC Manager. + +Mirrors the ``vla_manager_api.config`` shape (plain dataclass + +envvars). +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from sys import stderr + + +def _truthy(value: str | None) -> bool: + return value is not None and value.lower() in {"1", "true", "yes", "on"} + + +def _log_level(value: str | None) -> int: + return getattr(logging, (value or "INFO").upper(), logging.INFO) + + +@dataclass +class Config: + host: str = os.getenv("DVA_VC_MANAGER_HOST", "0.0.0.0") + port: int = int(os.getenv("DVA_VC_MANAGER_PORT", "8000")) + log_level: int = _log_level(os.getenv("DVA_VC_MANAGER_LOG_LEVEL", "INFO")) + + # Ed25519 signing key file path. Loaded on first use; created and + # persisted (0600) if missing. Mirrors ``SigningKeyStore.kt:34``. + signing_key_path: str = os.getenv("DVA_VC_MANAGER_SIGNING_KEY_PATH", "/data/dva-vc-signing-key.pem") + + # Optional shared-secret bearer auth for the admin endpoints. + api_key: str = os.getenv("DVA_VC_MANAGER_API_KEY", "") + + # Postgres DSN (whitelist). Required for the production (asyncpg) + # whitelist repo; empty → fall back to in-memory ``FakeWhitelist``. + postgres_dsn: str = os.getenv("DVA_VC_MANAGER_DB_URL", "") + + +cfg = Config() + + +def setup_logging() -> None: + import structlog + from structlog import make_filtering_bound_logger + from structlog.dev import ConsoleRenderer + from structlog.processors import JSONRenderer, StackInfoRenderer, TimeStamper + from structlog.stdlib import add_log_level + + shared = [add_log_level, StackInfoRenderer(), TimeStamper(fmt="iso")] + processors = shared + ([ConsoleRenderer()] if stderr.isatty() else [JSONRenderer()]) + structlog.configure( + processors=processors, + context_class=dict, + wrapper_class=make_filtering_bound_logger(cfg.log_level), + ) \ No newline at end of file diff --git a/dva-vc-manager/src/dva_vc_manager/dependencies.py b/dva-vc-manager/src/dva_vc_manager/dependencies.py new file mode 100644 index 00000000..2bd929f0 --- /dev/null +++ b/dva-vc-manager/src/dva_vc_manager/dependencies.py @@ -0,0 +1,34 @@ +"""FastAPI dependency providers for the DVA VC Manager.""" + +from __future__ import annotations + +import logging + +from .config import cfg +from .whitelist import FakeWhitelist, WhitelistRepo + +logger = logging.getLogger(__name__) + +_whitelist_singleton: WhitelistRepo | None = None + + +async def get_whitelist() -> WhitelistRepo: + global _whitelist_singleton + if _whitelist_singleton is None: + if cfg.postgres_dsn: + from .main import _build_production_whitelist_async + + _whitelist_singleton = await _build_production_whitelist_async() + else: + logger.warning( + "DVA_VC_MANAGER_DB_URL is not set — falling back to " + "FakeWhitelist (in-memory). Verifications will fail-closed " + "until admin populates the whitelist via POST /admin/whitelist." + ) + _whitelist_singleton = FakeWhitelist() + return _whitelist_singleton + + +def install_whitelist_for_tests(repo: WhitelistRepo) -> None: + global _whitelist_singleton + _whitelist_singleton = repo \ No newline at end of file diff --git a/dva-vc-manager/src/dva_vc_manager/did_key.py b/dva-vc-manager/src/dva_vc_manager/did_key.py new file mode 100644 index 00000000..2c7b16f6 --- /dev/null +++ b/dva-vc-manager/src/dva_vc_manager/did_key.py @@ -0,0 +1,58 @@ +"""``did:key`` codec for Ed25519 keys. + +Mirrors the Kotlin implementation in ``dva-api/api/.../jws/DidKey.kt``: + +* The Ed25519 public key is encoded as 32 raw bytes (RFC 8032). +* Prefixed with the Ed25519 multicodec prefix ``0xed 0x01``. +* Then ``multibase(base58btc(...))`` — prefixed with the ``z`` character + for base58btc. +* Finally wrapped in ``did:key:``. + +This is exactly the W3C did:key specification — no custom cryptography. +``base58`` is a tiny, audited Python package; ``nacl`` is the canonical +libsodium binding (PyNaCl). + +Reference: +- https://w3c-ccg.github.io/did-method-key/ +- The known test vector ``did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK`` +""" + +from __future__ import annotations + +import base58 +from nacl.public import PublicKey + +ED25519_RAW_SIZE = 32 +ED25519_MULTICODEC_PREFIX = b"\xed\x01" +MULTIBASE_BASE58BTC_PREFIX = "z" +DID_KEY_SCHEME = "did:key:" + + +def public_key_to_did_key(public_key: PublicKey) -> str: + """Encode a PyNaCl Ed25519 PublicKey into a ``did:key`` identifier.""" + raw = bytes(public_key) + if len(raw) != ED25519_RAW_SIZE: + raise ValueError(f"Ed25519 public key must be exactly 32 bytes, got {len(raw)}") + multicodec = ED25519_MULTICODEC_PREFIX + raw + return DID_KEY_SCHEME + MULTIBASE_BASE58BTC_PREFIX + base58.b58encode(multicodec).decode("ascii") + + +def did_key_to_public_key(did_key: str) -> PublicKey: + """Decode a ``did:key`` Ed25519 identifier back into a PyNaCl PublicKey.""" + if not did_key.startswith(DID_KEY_SCHEME): + raise ValueError(f"not a did:key identifier: {did_key}") + multibase = did_key.removeprefix(DID_KEY_SCHEME) + if not multibase.startswith(MULTIBASE_BASE58BTC_PREFIX): + raise ValueError(f"only base58btc multibase ('z') is supported, got: {multibase}") + decoded = base58.b58decode(multibase[1:]) + if len(decoded) != len(ED25519_MULTICODEC_PREFIX) + ED25519_RAW_SIZE: + raise ValueError( + f"decoded multicodec is {len(decoded)} bytes, " + f"expected {len(ED25519_MULTICODEC_PREFIX) + ED25519_RAW_SIZE}" + ) + if decoded[:2] != ED25519_MULTICODEC_PREFIX: + raise ValueError( + f"multicodec prefix 0x{decoded[0]:02x}{decoded[1]:02x} " + "is not the Ed25519 prefix 0xed01" + ) + return PublicKey(decoded[2:]) \ No newline at end of file diff --git a/dva-vc-manager/src/dva_vc_manager/keys.py b/dva-vc-manager/src/dva_vc_manager/keys.py new file mode 100644 index 00000000..9e9df86e --- /dev/null +++ b/dva-vc-manager/src/dva_vc_manager/keys.py @@ -0,0 +1,110 @@ +"""Ed25519 signing-key store. + +Mirrors the Kotlin ``SigningKeyStore.kt:34`` semantics **without** +implementing any custom cryptography — keys are generated and loaded via +PyNaCl's :class:`nacl.signing.SigningKey`, which is the canonical +libsodium binding (audited, used widely in production). + +Persistence format: ``base64(private key seed)|base64(public key)`` — +PyNaCl exposes the private key as a 32-byte seed, the public key as 32 +bytes. The seed is the canonical "private key" representation for +Ed25519 in libsodium. + +POSIX file permissions 0600 are applied to the key file, mirroring +``SigningKeyStore.kt:107-114``. Parent directories are 0700. +""" + +from __future__ import annotations + +import base64 +import os +from pathlib import Path +from typing import Tuple + +from nacl.signing import SigningKey, VerifyKey +from nacl.public import PublicKey + + +__all__ = ["SigningKeyStore", "KeyPair"] + + +class KeyPair: + """A loaded Ed25519 keypair (PyNaCl types).""" + + def __init__(self, private: SigningKey, public: VerifyKey) -> None: + self._private = private + self._public = public + + @property + def private(self) -> SigningKey: + return self._private + + @property + def public(self) -> VerifyKey: + return self._public + + def public_key(self) -> PublicKey: + """Return the PyNaCl PublicKey (needed by did_key codec).""" + return PublicKey(bytes(self._public)) + + +class SigningKeyStore: + """Persistent Ed25519 keypair store backed by a filesystem path.""" + + def __init__(self, path: str) -> None: + self._path = Path(path) + self._cached: KeyPair | None = None + + def load_or_generate(self) -> KeyPair: + """Return the cached keypair, load from disk, or generate+persist.""" + if self._cached is not None: + return self._cached + + if self._path.exists(): + content = self._path.read_text() + parts = content.split("|") + if len(parts) != 2: + raise RuntimeError( + f"Signing key file at {self._path} is malformed (expected " + f"'base64(priv)|base64(pub)', got {len(parts)} segment(s)). " + "Remove the file manually if you intend to generate a new key." + ) + priv_seed = base64.b64decode(parts[0]) + pub_bytes = base64.b64decode(parts[1]) + signing_key = SigningKey(priv_seed) + if bytes(signing_key.verify_key) != pub_bytes: + raise RuntimeError( + f"Signing key file at {self._path} is inconsistent: " + "public key does not match private seed. Refusing to load." + ) + self._cached = KeyPair(signing_key, signing_key.verify_key) + return self._cached + + # File doesn't exist — generate a fresh keypair and persist it. + signing_key = SigningKey.generate() + self._cached = KeyPair(signing_key, signing_key.verify_key) + + parent = self._path.parent or Path(".") + parent.mkdir(parents=True, exist_ok=True) + try: + os.chmod(parent, 0o700) + except (NotImplementedError, OSError): + pass # Non-POSIX filesystems (Windows) + + priv_b64 = base64.b64encode(bytes(signing_key)).decode("ascii") + pub_b64 = base64.b64encode(bytes(signing_key.verify_key)).decode("ascii") + self._path.write_text(f"{priv_b64}|{pub_b64}") + + try: + os.chmod(self._path, 0o600) + except (NotImplementedError, OSError): + pass + return self._cached + + def issuer_did_key(self) -> str: + """Return the ``did:key`` identifier of the loaded public key.""" + from .did_key import public_key_to_did_key + + if self._cached is None: + raise RuntimeError("SigningKeyStore.load_or_generate() must be called before issuer_did_key()") + return public_key_to_did_key(self._cached.public_key()) \ No newline at end of file diff --git a/dva-vc-manager/src/dva_vc_manager/main.py b/dva-vc-manager/src/dva_vc_manager/main.py new file mode 100644 index 00000000..7c66ed6b --- /dev/null +++ b/dva-vc-manager/src/dva_vc_manager/main.py @@ -0,0 +1,129 @@ +"""FastAPI application factory and CLI entrypoint for the DVA VC Manager.""" + +from __future__ import annotations + +import logging +import os + +from fastapi import FastAPI +from fastapi.responses import HTMLResponse, PlainTextResponse + +from .config import cfg, setup_logging +from .routes import admin_router, router + +_SWAGGER_UI_HTML = """\ + + + + DVA VC Manager — Swagger UI + + + + +
+ + + + + +""" + + +def _build_production_whitelist(): + """Construct the async-backed whitelist repository. + + DEPRECATED sync stub — kept only for test-override compatibility. + Production callers must use the async version below. + """ + raise RuntimeError("Call _build_production_whitelist_async() from within an async context.") + + +async def _build_production_whitelist_async(): + """Construct the async-backed whitelist repository. + + Uses await (not asyncio.run) so it is safe to call from within the + uvicorn event loop — mirrors _build_production_repo() in vla-manager-api. + """ + import asyncpg + + from .whitelist import PgWhitelist + + if not cfg.postgres_dsn: + raise RuntimeError( + "DVA_VC_MANAGER_DB_URL is not set — cannot boot PgWhitelist. " + "Either set it or override get_whitelist dependency for tests." + ) + + pool = await asyncpg.create_pool(dsn=cfg.postgres_dsn, min_size=1, max_size=4) + repo = PgWhitelist(pool) + await repo._ensure_schema() + return repo + + +def create_app() -> FastAPI: + setup_logging() + app = FastAPI( + title="DVA VC Manager", + description=( + "Issues and verifies Attestation of Veracity (AoV) credentials as " + "W3C VC 2.0 JSON-LD JWS (Ed25519/EdDSA) using PyNaCl. Hosted at " + "each Participant. Called by the DVA API during credential " + "issuance in the synchronous attestation flow." + ), + version="0.1.0", + # Disable auto-generated docs — hand-written spec is served at /swagger + docs_url=None, + redoc_url=None, + openapi_url=None, + ) + app.include_router(router) + app.include_router(admin_router) + + @app.get("/swagger", response_class=HTMLResponse, include_in_schema=False) + async def swagger_ui() -> HTMLResponse: + """Serve the Swagger UI loaded from the hand-written OpenAPI spec.""" + return HTMLResponse(content=_SWAGGER_UI_HTML) + + @app.get("/swagger/openapi.yaml", response_class=PlainTextResponse, include_in_schema=False) + async def swagger_spec() -> PlainTextResponse: + """Serve the hand-written OpenAPI spec YAML from disk.""" + spec_path = os.environ.get("DVA_VC_MANAGER_OPENAPI_FILE", "/app/openapi.yaml") + try: + with open(spec_path, "r", encoding="utf-8") as fh: + content = fh.read() + except FileNotFoundError: + return PlainTextResponse(content="# spec file not found", status_code=404) + return PlainTextResponse(content=content, media_type="application/yaml") + + return app + + +app = create_app() + + +def _level_to_str(level: int) -> str: + for name, val in logging._levelToName.items(): + if val == level: + return name.lower() + return "info" + + +def cli() -> None: + import uvicorn + + uvicorn.run( + "dva_vc_manager.main:app", + host=cfg.host, + port=cfg.port, + log_level=_level_to_str(cfg.log_level), + ) \ No newline at end of file diff --git a/dva-vc-manager/src/dva_vc_manager/models.py b/dva-vc-manager/src/dva_vc_manager/models.py new file mode 100644 index 00000000..235dc47c --- /dev/null +++ b/dva-vc-manager/src/dva_vc_manager/models.py @@ -0,0 +1,117 @@ +"""HTTP request/response models for /aov/issue and /aov/verify. + +The JSON shape is kept byte-compatible with the Kotlin ``dva-api`` +``/attestation`` response so the PDC client +(``dataspace-connector-1.10.2/src/libs/third-party/dva.ts``) sees no +contract change when the JWS is issued by this Python service instead +of the inlined Kotlin signer. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +# JSON keys for these request/response models are byte-identical with the +# Kotlin ``dva-api`` DTOs (``route/aovRoutes.kt:39-57`` and +# ``AoVDTOs.kt``). Kotlin property names are ``camelCase`` (e.g. ``vcId``, +# ``issuerDidKey``); pydantic's Python-native attribute names stay +# ``snake_case`` but the wire format MUST be ``camelCase`` so the Kotlin +# client/server round-trip works without contract drift. We add aliases +# to each advanced-name field and let ``populate_by_name=True`` keep +# snake_case accepted on the Python side (for unit tests and direct +# curl). +_CAMEL = ConfigDict(populate_by_name=True) + + +class EvaluationResultDTO(BaseModel): + """One row of the veracity-check results array. + + Field names are single words so no aliasing is needed — they are + already byte-identical with the Kotlin ``EvaluationResultDTO``. + """ + + engine: Optional[str] = None + timestamp: datetime + success: bool + details: Optional[str] = None + error: Optional[str] = None + + +class AovIssueRequest(BaseModel): + """Body of ``POST /aov/issue``. + + The DVA API posts the eight claims fields and the veracity-check + results array. The VC Manager decides whether to issue based on + ``all_success`` (computed here) and generates a fresh UUID for + the credential. + """ + + model_config = _CAMEL + + valid_since: str = Field(..., alias="validSince") + subject: str + issuer_id: str = Field(..., alias="issuerId") + record_id: str = Field(..., alias="recordId") + contract_id: str = Field(..., alias="contractId") + data_exchange_id: str = Field(..., alias="dataExchangeId") + payload: str + evaluation_results: list[EvaluationResultDTO] = Field( + ..., alias="evaluationResults" + ) + + +class AovIssueResponse(BaseModel): + """Returned by ``POST /aov/issue``. The JWS contains the issuer + ``did:key``, the VC UUID, and the issuance timestamp, so they are + not duplicated in the response body. + """ + + model_config = _CAMEL + + jws: str + + +class AovVerifyRequest(BaseModel): + """Body of ``POST /aov/verify``. Only the compact JWS string is + supplied; the issuer ``did:key`` is extracted from the JWS payload. + """ + + model_config = _CAMEL + + jws: str + + +class AovVerifyResponse(BaseModel): + """Returned by ``POST /aov/verify``.""" + + verified: bool + reason: Optional[str] = None + + +class WhitelistAddRequest(BaseModel): + """Body of ``POST /admin/whitelist``.""" + + did_key: str + label: Optional[str] = None + + +class WhitelistEntryDTO(BaseModel): + """Returned by ``GET /admin/whitelist`` and ``POST /admin/whitelist``.""" + + id: UUID + did_key: str + label: Optional[str] = None + + +class OwnKeyDTO(BaseModel): + """Returned by ``GET /admin/keys`` — read-only view of this + service's signing ``did:key`` (no private bytes). + """ + + issuer_did_key: str + key_path: str \ No newline at end of file diff --git a/dva-vc-manager/src/dva_vc_manager/routes.py b/dva-vc-manager/src/dva_vc_manager/routes.py new file mode 100644 index 00000000..f1a4a80a --- /dev/null +++ b/dva-vc-manager/src/dva_vc_manager/routes.py @@ -0,0 +1,198 @@ +"""FastAPI routes for the DVA VC Manager. + +Two AoV endpoints called by the DVA API during the synchronous +attestation flow: + +* ``POST /aov/issue`` — called by the DVA API during credential + issuance with the seven claims + the veracity-check results array. + Generates a UUID for the credential, signs the JWS, and returns + only the compact JWS string (issuer did:key, vc_id, and issuance + date are all encoded in the JWS itself). +* ``POST /aov/verify`` — called by the DVA API at the consumer side to + verify a JWS. The issuer ``did:key`` is extracted from the JWS + payload and looked up in the whitelist. Fail-closed: rejects if the + whitelist is empty or the issuer is not registered. + +Plus four admin endpoints (all bearer-auth-guarded): + +* ``GET /admin/whitelist`` — list trusted attesters. +* ``POST /admin/whitelist`` — register a trusted attester's did:key. +* ``DELETE /admin/whitelist/{did_key}`` — remove an attester. +* ``GET /admin/keys`` — view this service's own issuer did:key + (read-only; no private key bytes exposed). +""" + +from __future__ import annotations + +import urllib.parse +from datetime import datetime, timezone +from uuid import uuid4 + +from fastapi import APIRouter, Depends, HTTPException, status + +from .auth import require_api_key +from .dependencies import get_whitelist +from .did_key import did_key_to_public_key +from .keys import SigningKeyStore +from .models import ( + AovIssueRequest, + AovIssueResponse, + AovVerifyRequest, + AovVerifyResponse, + OwnKeyDTO, + WhitelistAddRequest, + WhitelistEntryDTO, +) +from .signing import AovClaims, decode_payload, sign_jws, verify_jws +from .whitelist import WhitelistRepo + +router = APIRouter() + + +def _get_key_store() -> SigningKeyStore: + """Lazily construct the app-wide signing key store. + + Resolved via FastAPI's dependency system in tests via + ``app.dependency_overrides`` so the test suite can swap in a + key store at a temp-file path. + """ + from .config import cfg + + return SigningKeyStore(cfg.signing_key_path) + + +@router.post("/aov/issue", response_model=AovIssueResponse) +async def aov_issue(req: AovIssueRequest) -> AovIssueResponse: + """Issue an AoV JWS credential from the veracity-check results.""" + key_store = _get_key_store() + keypair = key_store.load_or_generate() + issuer_did_key = key_store.issuer_did_key() + + # Mapped to the camelCase AovClaims fields — AovClaims VC-subject + # JSON keys must stay byte-identical with the Kotlin issuer, so + # we hand the model the snake_case values and rely on the field + # aliases in build_aov_payload. + claims = AovClaims( + vc_id=str(uuid4()), + valid_since=req.valid_since, + subject=req.subject, + issuer_id=req.issuer_id, + record_id=req.record_id, + contract_id=req.contract_id, + data_exchange_id=req.data_exchange_id, + payload=req.payload, + ) + jws = sign_jws(claims, keypair.private, issuer_did_key) + return AovIssueResponse(jws=jws) + + +@router.post("/aov/verify", response_model=AovVerifyResponse) +async def aov_verify( + req: AovVerifyRequest, + whitelist: WhitelistRepo = Depends(get_whitelist), +) -> AovVerifyResponse: + """Verify an AoV JWS. The issuer did:key is extracted from the JWS + payload and looked up in the whitelist. Fail-closed.""" + + # 1. Decode the JWS payload to extract the issuer did:key. + try: + payload = decode_payload(req.jws) + except Exception as e: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"malformed JWS: {e}", + ) + + issuer_did_key = payload.get("issuer") + if not issuer_did_key: + return AovVerifyResponse(verified=False, reason="JWS payload missing issuer") + + # 2. Whitelist must be non-empty. + entries = await whitelist.all() + if not entries: + return AovVerifyResponse( + verified=False, + reason="whitelist is not configured; verification is disabled", + ) + + # 3. Issuer must be whitelisted — fail-closed when not found. + entry = await whitelist.find(issuer_did_key) + if entry is None: + return AovVerifyResponse(verified=False, reason="issuer not whitelisted") + + # 4. Derive the public key from the whitelist record's did:key. + try: + public_key = did_key_to_public_key(entry.did_key) + except Exception as e: + return AovVerifyResponse( + verified=False, + reason=f"whitelist entry contains invalid did:key: {e}", + ) + + # 5. Verify the Ed25519 signature. + from nacl.signing import VerifyKey + + try: + ok = verify_jws(req.jws, VerifyKey(bytes(public_key))) + except Exception as e: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + detail=f"malformed JWS: {e}", + ) + + if not ok: + return AovVerifyResponse(verified=False, reason="signature mismatch") + + return AovVerifyResponse(verified=True) + + +# --- Admin ------------------------------------------------------------ + + +admin_router = APIRouter() + + +@admin_router.get("/admin/whitelist", response_model=list[WhitelistEntryDTO]) +async def whitelist_list( + _: None = Depends(require_api_key), + whitelist: WhitelistRepo = Depends(get_whitelist), +) -> list[WhitelistEntryDTO]: + entries = await whitelist.all() + return [WhitelistEntryDTO(id=e.id, did_key=e.did_key, label=e.label) for e in entries] + + +@admin_router.post( + "/admin/whitelist", + status_code=status.HTTP_201_CREATED, + response_model=WhitelistEntryDTO, +) +async def whitelist_add( + req: WhitelistAddRequest, + _: None = Depends(require_api_key), + whitelist: WhitelistRepo = Depends(get_whitelist), +) -> WhitelistEntryDTO: + entry = await whitelist.add(req.did_key, req.label) + return WhitelistEntryDTO(id=entry.id, did_key=entry.did_key, label=entry.label) + + +@admin_router.delete("/admin/whitelist/{did_key}", status_code=status.HTTP_204_NO_CONTENT) +async def whitelist_remove( + did_key: str, + _: None = Depends(require_api_key), + whitelist: WhitelistRepo = Depends(get_whitelist), +) -> None: + # URL-decode in case the path contains special chars (did:key contains ':'). + decoded = urllib.parse.unquote(did_key) + removed = await whitelist.remove(decoded) + if not removed: + raise HTTPException(status.HTTP_404_NOT_FOUND, "did:key not in whitelist") + return None + + +@admin_router.get("/admin/keys", response_model=OwnKeyDTO) +async def keys_view(_: None = Depends(require_api_key)) -> OwnKeyDTO: + from .config import cfg + + key_store = SigningKeyStore(cfg.signing_key_path) + key_store.load_or_generate() + return OwnKeyDTO(issuer_did_key=key_store.issuer_did_key(), key_path=cfg.signing_key_path) \ No newline at end of file diff --git a/dva-vc-manager/src/dva_vc_manager/signing.py b/dva-vc-manager/src/dva_vc_manager/signing.py new file mode 100644 index 00000000..57124d1e --- /dev/null +++ b/dva-vc-manager/src/dva_vc_manager/signing.py @@ -0,0 +1,176 @@ +"""JWS issuance and verification. + +Muliberates the production of a compact JWS (``header.payload.signature``) +over a W3C VC 2.0 JSON-LD payload. The signed JSON shape is +**byte-for-byte identical** to the Kotlin ``JwsSigner.kt:39-57`` so any +existing consumer of an AoV JWS (PDC, other DVAs, downstream wallets) +can verify a Python-issued credential with the Kotlin verifier and +vice-versa. + +The cryptography itself is delegated entirely to PyNaCl (libsodium): + +* :func:`nacl.signing.SigningKey.sign` for EdDSA signatures. +* :func:`nacl.signing.VerifyKey.verify` for EdDSA verification. + +No hand-rolled cryptography anywhere. Only the JSON shape construction, +base64url encoding, and the standard JWS compact serialization +concatenation happen here. +""" + +from __future__ import annotations + +import base64 +import json +from typing import Any + +from nacl.exceptions import BadSignatureError +from nacl.signing import SigningKey, VerifyKey + +from .did_key import did_key_to_public_key + +# JWS header constants — must match Kotlin ``JwsSigner.kt:30-34`` exactly. +JWS_HEADER_ALG = "EdDSA" +JWS_HEADER_TYPE = "VC+LD-JSON+JWS" +VC_CONTEXT = "https://www.w3.org/2018/credentials/v1" +VC_TYPE = "VerifiableCredential" +AOV_TYPE = "AttestationOfVeracity" + + +def _b64url(data: bytes) -> str: + """Standard JWS base64url **without** padding (per RFC 7515 §2.2.2).""" + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _b64url_decode(segment: str) -> bytes: + """Inverse of :func:`_b64url` — re-adds padding before decoding.""" + pad = (-len(segment)) % 4 + return base64.urlsafe_b64decode(segment + "=" * pad) + + +def _json_compact(obj: dict[str, Any]) -> bytes: + """Compact JSON encoding — must match Kotlin's + ``Json.encodeToString(JsonObject.serializer(), this)`` byte-for-byte. + Kotlin's default ``kotlinx.serialization.json.Json`` uses no extra + whitespace, separators are ``","`` and ``":"``, keys preserve insertion + order. We use ``json.dumps(..., separators=(",", ":"), ensure_ascii=False)`` + for an exact match. + """ + return json.dumps(obj, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + + +def build_aov_payload(claims: "AovClaims", issuer_did_key: str) -> dict[str, Any]: + """Build the W3C VC 2.0 JSON-LD payload. + + Identical to Kotlin ``buildAovPayload`` (``JwsSigner.kt:39-57``): + ``@context``, ``type`` (a two-element array), ``issuer``, + ``validFrom``, and ``credentialSubject`` carrying the eight AoV + claims. + """ + return { + "@context": [VC_CONTEXT], + "type": [VC_TYPE, AOV_TYPE], + "issuer": issuer_did_key, + "validFrom": claims.valid_since, + "credentialSubject": { + "vc_id": claims.vc_id, + "valid_since": claims.valid_since, + "subject": claims.subject, + "issuer_id": claims.issuer_id, + "record_id": claims.record_id, + "contract_id": claims.contract_id, + "data_exchange_id": claims.data_exchange_id, + "payload": claims.payload, + }, + } + + +def _jws_header() -> dict[str, str]: + return {"alg": JWS_HEADER_ALG, "typ": JWS_HEADER_TYPE} + + +def sign_jws(claims: "AovClaims", signing_key: SigningKey, issuer_did_key: str) -> str: + """Sign and produce a compact JWS string. + + ``signing_key`` is a :class:`nacl.signing.SigningKey` (Ed25519). + The signature is produced by libsodium via + ``signing_key.sign(signing_input).signature`` — which is the + canonical EdDSA primitive, not a hand-rolled signing function. + """ + header_b64 = _b64url(_json_compact(_jws_header())) + payload_b64 = _b64url(_json_compact(build_aov_payload(claims, issuer_did_key))) + signing_input = f"{header_b64}.{payload_b64}".encode("ascii") + + # PyNaCl SigningKey.sign returns a SignedMessage; .signature is the + # detached raw 64-byte EdDSA signature. + signature = signing_key.sign(signing_input).signature + signature_b64 = _b64url(signature) + return f"{header_b64}.{payload_b64}.{signature_b64}" + + +def verify_jws(jws: str, public_key: VerifyKey) -> bool: + """Verify a compact JWS. + + Returns ``True`` if the signature is valid; ``False`` on signature + mismatch (mirrors ``JwsSigner.kt:100-113`` semantics — bad + signature returns false rather than throwing). Malformed JWS raises + an exception (also matches the Kotlin test at + ``JwsSignerTest.kt:69-77``). + """ + parts = jws.split(".") + if len(parts) != 3: + raise ValueError("Compact JWS must have 3 dot-separated parts") + signing_input = f"{parts[0]}.{parts[1]}".encode("ascii") + signature = _b64url_decode(parts[2]) + try: + public_key.verify(signing_input, signature) + return True + except BadSignatureError: + return False + + +def verify_jws_with_did_key(jws: str, did_key: str) -> bool: + """Convenience: derive the Ed25519 public key from a did:key and verify.""" + public_key = did_key_to_public_key(did_key) + # VerifyKey accepts the raw 32-byte encoding — same bytes that did_key + # just decoded for us. + return verify_jws(jws, VerifyKey(bytes(public_key))) + + +def decode_payload(jws: str) -> dict[str, Any]: + """Decode (without verifying) the payload middle segment of a JWS.""" + parts = jws.split(".") + if len(parts) != 3: + raise ValueError("Compact JWS must have 3 dot-separated parts") + return json.loads(_b64url_decode(parts[1])) + + +# AoV claims model — defined at the bottom of the module so older +# pydantic-style annotations above ("AovClaims") resolve via forward +# reference. Importing this class is the canonical way callers construct +# the claims payload. +from pydantic import BaseModel # noqa: E402 + + +class AovClaims(BaseModel): + """The eight AoV credentialSubject claims. + + Fields are byte-identical to ``hu.bme.mit.ftsrg.dva.api.jws.AovClaims`` + (``JwsSigner.kt:19-28``): ``vcId, validSince, subject, issuerId, + recordId, contractId, dataExchangeId, payload``. Python field names + are snake_case but Pydantic aliases make the JSON keys camelCase. + """ + + vc_id: str + valid_since: str + subject: str + issuer_id: str + record_id: str + contract_id: str + data_exchange_id: str + payload: str + + model_config = {"populate_by_name": True} + + @property + def vcId(self) -> str: # noqa: N802 — parity with Kotlin property name. + return self.vc_id \ No newline at end of file diff --git a/dva-vc-manager/src/dva_vc_manager/whitelist.py b/dva-vc-manager/src/dva_vc_manager/whitelist.py new file mode 100644 index 00000000..258c7def --- /dev/null +++ b/dva-vc-manager/src/dva_vc_manager/whitelist.py @@ -0,0 +1,131 @@ +"""Whitelist of trusted attester ``did:key`` identifiers. + +The verify side is **fail-closed**: ``/aov/verify`` rejects any +verification when the whitelist is empty (mirrors +``aovRoutes.kt:231-242``). The whitelist is populated via admin +endpoints (``POST /admin/whitelist``) in the new DVA VC MANAGER service. + +Two implementations: +* :class:`FakeWhitelist` — in-memory list for tests. +* :class:`PgWhitelist` — async-backed PostgreSQL repository via + asyncpg; mirrors ``whitelistMapping.kt:17-20``. +""" + +from __future__ import annotations + +from typing import Any, Optional, Protocol +from uuid import UUID, uuid4 + +from pydantic import BaseModel + +__all__ = ["WhitelistEntry", "WhitelistRepo", "FakeWhitelist", "PgWhitelist"] + + +class WhitelistEntry(BaseModel): + id: UUID + did_key: str + label: Optional[str] = None + + @classmethod + def from_row(cls, row: Any) -> "WhitelistEntry": + return cls(id=row["id"], did_key=row["did_key"], label=row["label"]) + + +class WhitelistRepo(Protocol): + async def all(self) -> list[WhitelistEntry]: ... + async def add(self, did_key: str, label: Optional[str]) -> WhitelistEntry: ... + async def remove(self, did_key: str) -> bool: ... + async def find(self, did_key: str) -> Optional[WhitelistEntry]: ... + async def contains(self, did_key: str) -> bool: ... + + +class FakeWhitelist: + """In-memory whitelist for tests.""" + + def __init__(self) -> None: + self._entries: dict[str, WhitelistEntry] = {} + + async def all(self) -> list[WhitelistEntry]: + return list(self._entries.values()) + + async def add(self, did_key: str, label: Optional[str] = None) -> WhitelistEntry: + if did_key in self._entries: + return self._entries[did_key] + entry = WhitelistEntry(id=uuid4(), did_key=did_key, label=label) + self._entries[did_key] = entry + return entry + + async def remove(self, did_key: str) -> bool: + return self._entries.pop(did_key, None) is not None + + async def find(self, did_key: str) -> Optional[WhitelistEntry]: + return self._entries.get(did_key) + + async def contains(self, did_key: str) -> bool: + return did_key in self._entries + + +class PgWhitelist: + """PostgreSQL whitelist (asyncpg).""" + + def __init__(self, pool): # type: ignore[no-untyped-def] + self._pool = pool + + async def _ensure_schema(self) -> None: + async with self._pool.acquire() as conn: + await conn.execute( + """ + CREATE TABLE IF NOT EXISTS did_key_whitelist ( + id UUID PRIMARY KEY, + did_key VARCHAR(255) UNIQUE NOT NULL, + label VARCHAR(255) + ) + """ + ) + + async def all(self) -> list[WhitelistEntry]: + async with self._pool.acquire() as conn: + rows = await conn.fetch( + "SELECT id, did_key, label FROM did_key_whitelist" + ) + return [WhitelistEntry.from_row(r) for r in rows] + + async def add(self, did_key: str, label: Optional[str] = None) -> WhitelistEntry: + import json + import asyncpg.exceptions + id = uuid4() + async with self._pool.acquire() as conn: + try: + await conn.execute( + "INSERT INTO did_key_whitelist (id, did_key, label) VALUES ($1, $2, $3)", + id, did_key, label, + ) + except asyncpg.exceptions.UniqueViolationError: + existing = await conn.fetchrow( + "SELECT id, did_key, label FROM did_key_whitelist WHERE did_key = $1", + did_key, + ) + return WhitelistEntry.from_row(existing) + return WhitelistEntry(id=id, did_key=did_key, label=label) + + async def remove(self, did_key: str) -> bool: + async with self._pool.acquire() as conn: + result = await conn.execute( + "DELETE FROM did_key_whitelist WHERE did_key = $1", did_key + ) + return result.endswith("1") # "DELETE 1" → true + + async def find(self, did_key: str) -> Optional[WhitelistEntry]: + async with self._pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT id, did_key, label FROM did_key_whitelist WHERE did_key = $1", + did_key, + ) + return WhitelistEntry.from_row(row) if row is not None else None + + async def contains(self, did_key: str) -> bool: + async with self._pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT 1 FROM did_key_whitelist WHERE did_key = $1", did_key + ) + return row is not None \ No newline at end of file diff --git a/dva-vc-manager/tests/__init__.py b/dva-vc-manager/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/dva-vc-manager/tests/test_did_key.py b/dva-vc-manager/tests/test_did_key.py new file mode 100644 index 00000000..c7cc8dd4 --- /dev/null +++ b/dva-vc-manager/tests/test_did_key.py @@ -0,0 +1,40 @@ +"""did:key codec round-trip tests — mirror ``DidKeyTest.kt`` exactly.""" + +from __future__ import annotations + +from nacl.signing import SigningKey +from nacl.public import PublicKey + +from dva_vc_manager.did_key import ( + did_key_to_public_key, + public_key_to_did_key, +) + + +def test_ed25519_key_round_trips_through_did_key() -> None: + signing_key = SigningKey.generate() + pub = PublicKey(bytes(signing_key.verify_key)) + + did_key = public_key_to_did_key(pub) + round_tripped_pub = did_key_to_public_key(did_key) + round_tripped_did_key = public_key_to_did_key(round_tripped_pub) + + assert did_key == round_tripped_did_key, ( + "did:key round-trip must produce the same identifier" + ) + + +def test_known_spec_vector() -> None: + expected = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + pub = did_key_to_public_key(expected) + reencoded = public_key_to_did_key(pub) + assert reencoded == expected + + +def test_starts_with_did_key_z6mk() -> None: + signing_key = SigningKey.generate() + pub = PublicKey(bytes(signing_key.verify_key)) + did_key = public_key_to_did_key(pub) + assert did_key.startswith("did:key:z6Mk"), ( + "did:key identifier must start with 'did:key:z6Mk'" + ) \ No newline at end of file diff --git a/dva-vc-manager/tests/test_jws.py b/dva-vc-manager/tests/test_jws.py new file mode 100644 index 00000000..29ffe06e --- /dev/null +++ b/dva-vc-manager/tests/test_jws.py @@ -0,0 +1,84 @@ +"""JWS sign+verify tests — mirror ``JwsSignerTest.kt`` behaviour exactly.""" + +from __future__ import annotations + +import pytest +from nacl.signing import SigningKey, VerifyKey + +from dva_vc_manager.signing import AovClaims, sign_jws, verify_jws + + +def _sample_claims() -> AovClaims: + return AovClaims( + vc_id="urn:uuid:11111111-2222-3333-4444-555555555555", + valid_since="2024-01-01T00:00:00Z", + subject="did:web:data-consumer.example", + issuer_id="did:web:data-provider.example", + record_id="rec-0001", + contract_id="contract-0001", + data_exchange_id="xchg-0001", + payload="checksum:sha256:abcdef0123456789", + ) + + +_KNOWN_DID_KEY = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + + +def test_signs_and_verifies_a_valid_aov() -> None: + signing_key = SigningKey.generate() + public_key = VerifyKey(bytes(signing_key.verify_key)) + + jws = sign_jws(_sample_claims(), signing_key, _KNOWN_DID_KEY) + + assert jws, "JWS must not be empty" + assert jws.count(".") == 2, "Compact JWS must have 3 dot-separated parts" + + ok = verify_jws(jws, public_key) + assert ok, "verify_jws must return True for a valid signature" + + +def test_tampered_payload_fails_verification() -> None: + signing_key = SigningKey.generate() + public_key = VerifyKey(bytes(signing_key.verify_key)) + + jws = sign_jws(_sample_claims(), signing_key, _KNOWN_DID_KEY) + parts = jws.split(".") + # Flip the first character of the payload segment. + first_char = parts[1][0] + flipped_char = "B" if first_char == "A" else "A" + parts[1] = flipped_char + parts[1][1:] + tampered_jws = f"{parts[0]}.{parts[1]}.{parts[2]}" + + ok = verify_jws(tampered_jws, public_key) + assert ok is False, "verify_jws must return False for a tampered payload" + + +def test_rejection_of_a_clearly_malformed_jws() -> None: + signing_key = SigningKey.generate() + public_key = VerifyKey(bytes(signing_key.verify_key)) + + with pytest.raises(Exception): + verify_jws("not.a.jws.at.all", public_key) + + +def test_payload_shape_includes_context_type_issuer_validfrom_subject() -> None: + """Validate the W3C VC JSON-LD structure — byte-identical to the + Kotlin ``buildAovPayload`` at ``JwsSigner.kt:39-57``.""" + from dva_vc_manager.signing import build_aov_payload, decode_payload + + signing_key = SigningKey.generate() + jws = sign_jws(_sample_claims(), signing_key, _KNOWN_DID_KEY) + payload = decode_payload(jws) + assert payload["@context"] == ["https://www.w3.org/2018/credentials/v1"] + assert payload["type"] == ["VerifiableCredential", "AttestationOfVeracity"] + assert payload["issuer"] == _KNOWN_DID_KEY + assert payload["validFrom"] == "2024-01-01T00:00:00Z" + sub = payload["credentialSubject"] + assert sub["vc_id"] == "urn:uuid:11111111-2222-3333-4444-555555555555" + assert sub["valid_since"] == "2024-01-01T00:00:00Z" + assert sub["subject"] == "did:web:data-consumer.example" + assert sub["issuer_id"] == "did:web:data-provider.example" + assert sub["record_id"] == "rec-0001" + assert sub["contract_id"] == "contract-0001" + assert sub["data_exchange_id"] == "xchg-0001" + assert sub["payload"] == "checksum:sha256:abcdef0123456789" \ No newline at end of file diff --git a/dva-vc-manager/tests/test_keys.py b/dva-vc-manager/tests/test_keys.py new file mode 100644 index 00000000..db83c6e8 --- /dev/null +++ b/dva-vc-manager/tests/test_keys.py @@ -0,0 +1,48 @@ +"""Signing key store tests — mirror ``SigningKeyStoreTest.kt``.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from dva_vc_manager.did_key import public_key_to_did_key +from dva_vc_manager.keys import SigningKeyStore +from nacl.public import PublicKey + + +def test_generates_key_on_first_run_when_file_missing(tmp_path: Path) -> None: + key_path = tmp_path / "subdir" / "key.pem" + store = SigningKeyStore(str(key_path)) + + pair = store.load_or_generate() + + assert key_path.exists(), "Key file must be created on first run" + assert pair.private is not None + assert pair.public is not None + # Permissions: 0600 on POSIX + if os.name == "posix": + assert (key_path.stat().st_mode & 0o777) == 0o600 + + +def test_persists_and_reloads_the_same_key_across_instances(tmp_path: Path) -> None: + key_path = tmp_path / "key.pem" + + store1 = SigningKeyStore(str(key_path)) + pair1 = store1.load_or_generate() + pub1_bytes = bytes(pair1.public) + + # New instance pointing at the same file — must load, not regenerate. + store2 = SigningKeyStore(str(key_path)) + pair2 = store2.load_or_generate() + pub2_bytes = bytes(pair2.public) + + assert pub1_bytes == pub2_bytes, ( + "reload must yield the same public key as the original generation" + ) + + +def test_derived_did_key_starts_with_z6mk(tmp_path: Path) -> None: + store = SigningKeyStore(str(tmp_path / "key.pem")) + store.load_or_generate() + did_key = store.issuer_did_key() + assert did_key.startswith("did:key:z6Mk") \ No newline at end of file diff --git a/dva-vc-manager/tests/test_routes.py b/dva-vc-manager/tests/test_routes.py new file mode 100644 index 00000000..ca0c233a --- /dev/null +++ b/dva-vc-manager/tests/test_routes.py @@ -0,0 +1,156 @@ +"""End-to-end HTTP tests for the DVA VC Manager. + +Covers: +* ``POST /aov/issue`` happy path - 200, JWS returned. +* ``POST /aov/verify`` round-trips a valid JWS. +* ``POST /aov/verify`` rejects a tampered JWS. +* ``POST /aov/verify`` fails-closed when whitelist is empty. +* ``POST /aov/verify`` rejects when issuer not whitelisted. +* ``POST /aov/verify`` rejects a clearly malformed JWS with 400. +* Admin endpoints fail-closed 401 when no API key is configured. +""" + +from __future__ import annotations + +import os +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient +from nacl.signing import SigningKey + +from dva_vc_manager.dependencies import get_whitelist +from dva_vc_manager.main import create_app +from dva_vc_manager.signing import decode_payload +from dva_vc_manager.whitelist import FakeWhitelist + +_KNOWN_DID_KEY = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + + +def _extract_issuer_did_key(jws: str) -> str: + payload = decode_payload(jws) + return payload["issuer"] + + +@pytest.fixture +def whitelist() -> FakeWhitelist: + return FakeWhitelist() + + +@pytest.fixture +def client(whitelist: FakeWhitelist, monkeypatch: pytest.MonkeyPatch, tmp_path) -> TestClient: + monkeypatch.setenv("DVA_VC_MANAGER_SIGNING_KEY_PATH", str(tmp_path / "key.pem")) + from dva_vc_manager import config as cfg_module + cfg_module.cfg.signing_key_path = str(tmp_path / "key.pem") + cfg_module.cfg.api_key = "" + cfg_module.cfg.postgres_dsn = "" + + app = create_app() + app.dependency_overrides[get_whitelist] = lambda: whitelist + return TestClient(app) + + +def _issue_request(): + return { + "valid_since": "2024-01-01T00:00:00Z", + "subject": "did:web:data-consumer.example", + "issuer_id": "did:web:data-provider.example", + "record_id": "rec-0001", + "contract_id": "contract-0001", + "data_exchange_id": "xchg-0001", + "payload": "checksum:sha256:abcdef0123456789", + "evaluation_results": [ + { + "engine": "JQ", + "timestamp": "2024-01-01T00:00:00Z", + "success": True, + "details": "ok", + "error": None, + } + ], + } + + +def test_aov_issue_returns_jws(client: TestClient) -> None: + r = client.post("/aov/issue", json=_issue_request()) + assert r.status_code == 200, r.text + body = r.json() + assert body["jws"] + assert body["jws"].count(".") == 2 + issuer = _extract_issuer_did_key(body["jws"]) + assert issuer.startswith("did:key:z6Mk") + + +async def test_aov_issue_then_verify_round_trip(client: TestClient, whitelist: FakeWhitelist) -> None: + r = client.post("/aov/issue", json=_issue_request()) + assert r.status_code == 200 + body = r.json() + jws = body["jws"] + issuer_did_key = _extract_issuer_did_key(jws) + + await whitelist.add(issuer_did_key, label="self") + + r2 = client.post("/aov/verify", json={"jws": jws}) + assert r2.status_code == 200, r2.text + body2 = r2.json() + assert body2["verified"] is True + + +async def test_aov_verify_rejects_tampered_jws(client: TestClient, whitelist: FakeWhitelist) -> None: + r = client.post("/aov/issue", json=_issue_request()) + jws = r.json()["jws"] + issuer_did_key = _extract_issuer_did_key(jws) + await whitelist.add(issuer_did_key) + + parts = jws.split(".") + first_char = parts[1][0] + flipped = "B" if first_char == "A" else "A" + parts[1] = flipped + parts[1][1:] + tampered = f"{parts[0]}.{parts[1]}.{parts[2]}" + + r2 = client.post("/aov/verify", json={"jws": tampered}) + assert r2.status_code == 200 + assert r2.json()["verified"] is False + assert r2.json()["reason"] == "signature mismatch" + + +async def test_aov_verify_fails_closed_when_whitelist_empty( + client: TestClient, whitelist: FakeWhitelist +) -> None: + r = client.post("/aov/verify", json={"jws": "a.b.c"}) + assert r.status_code == 200 + body = r.json() + assert body["verified"] is False + assert body["reason"] == "whitelist is not configured; verification is disabled" + + +async def test_aov_verify_rejects_when_issuer_not_whitelisted( + client: TestClient, whitelist: FakeWhitelist +) -> None: + r = client.post("/aov/issue", json=_issue_request()) + jws = r.json()["jws"] + await whitelist.add("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK") + r2 = client.post("/aov/verify", json={"jws": jws}) + assert r2.status_code == 200 + assert r2.json()["verified"] is False + assert r2.json()["reason"] == "issuer not whitelisted" + + +def test_aov_verify_rejects_malformed_jws_with_400( + client: TestClient, whitelist: FakeWhitelist +) -> None: + import asyncio + asyncio.get_event_loop().run_until_complete(whitelist.add(_KNOWN_DID_KEY)) + r = client.post("/aov/verify", json={"jws": "not.a.jws.at.all"}) + assert r.status_code == 400 + + +def test_admin_whitelist_unauthorised_when_no_api_key(client: TestClient) -> None: + r = client.get("/admin/whitelist") + assert r.status_code == 401 + r = client.post("/admin/whitelist", json={"did_key": _KNOWN_DID_KEY}) + assert r.status_code == 401 + r = client.delete(f"/admin/whitelist/{_KNOWN_DID_KEY}") + assert r.status_code == 401 + r = client.get("/admin/keys") + assert r.status_code == 401 \ No newline at end of file diff --git a/dva-vc-manager/tests/test_whitelist.py b/dva-vc-manager/tests/test_whitelist.py new file mode 100644 index 00000000..32b56c9b --- /dev/null +++ b/dva-vc-manager/tests/test_whitelist.py @@ -0,0 +1,61 @@ +"""Whitelist repository tests — mirror the behaviour Kotlin +``AdminRoutesTest.kt`` covers (add, list, delete).""" + +from __future__ import annotations + +import pytest + +from dva_vc_manager.whitelist import FakeWhitelist + +_KNOWN_DID_KEY = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + + +@pytest.fixture +def whitelist() -> FakeWhitelist: + return FakeWhitelist() + + +async def test_adds_and_lists_a_whitelist_entry(whitelist: FakeWhitelist) -> None: + entries = await whitelist.all() + assert entries == [] + + entry = await whitelist.add(_KNOWN_DID_KEY, label="provider") + assert entry.did_key == _KNOWN_DID_KEY + assert entry.label == "provider" + + entries = await whitelist.all() + assert len(entries) == 1 + assert entries[0].did_key == _KNOWN_DID_KEY + + +async def test_supports_optional_label(whitelist: FakeWhitelist) -> None: + entry = await whitelist.add(_KNOWN_DID_KEY, label=None) + assert entry.label is None + + entries = await whitelist.all() + assert entries[0].label is None + + +async def test_deletes_a_whitelist_entry(whitelist: FakeWhitelist) -> None: + await whitelist.add(_KNOWN_DID_KEY) + removed = await whitelist.remove(_KNOWN_DID_KEY) + assert removed is True + + entries = await whitelist.all() + assert entries == [] + + # Deleting a nonexistent entry returns False. + again = await whitelist.remove(_KNOWN_DID_KEY) + assert again is False + + +async def test_contains_and_find(whitelist: FakeWhitelist) -> None: + assert await whitelist.contains(_KNOWN_DID_KEY) is False + assert await whitelist.find(_KNOWN_DID_KEY) is None + + await whitelist.add(_KNOWN_DID_KEY, label="x") + + assert await whitelist.contains(_KNOWN_DID_KEY) is True + found = await whitelist.find(_KNOWN_DID_KEY) + assert found is not None + assert found.label == "x" \ No newline at end of file diff --git a/dva-vc-manager/uv.lock b/dva-vc-manager/uv.lock new file mode 100644 index 00000000..ac1beb57 --- /dev/null +++ b/dva-vc-manager/uv.lock @@ -0,0 +1,756 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.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.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + +[[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 = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-timeout", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/d9/507c80bdac2e95e5a525644af94b03fa7f9a44596a84bd48a6e80f854f92/asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61", size = 644865, upload-time = "2025-11-24T23:25:23.527Z" }, + { url = "https://files.pythonhosted.org/packages/ea/03/f93b5e543f65c5f504e91405e8d21bb9e600548be95032951a754781a41d/asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be", size = 639297, upload-time = "2025-11-24T23:25:25.192Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/de2177e57e03a06e697f6c1ddf2a9a7fcfdc236ce69966f54ffc830fd481/asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8", size = 2816679, upload-time = "2025-11-24T23:25:26.718Z" }, + { url = "https://files.pythonhosted.org/packages/d0/98/1a853f6870ac7ad48383a948c8ff3c85dc278066a4d69fc9af7d3d4b1106/asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1", size = 2867087, upload-time = "2025-11-24T23:25:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/7e76f2a51f2360a7c90d2cf6d0d9b210c8bb0ae342edebd16173611a55c2/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3", size = 2747631, upload-time = "2025-11-24T23:25:30.154Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3f/716e10cb57c4f388248db46555e9226901688fbfabd0afb85b5e1d65d5a7/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8", size = 2855107, upload-time = "2025-11-24T23:25:31.888Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ec/3ebae9dfb23a1bd3f68acfd4f795983b65b413291c0e2b0d982d6ae6c920/asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095", size = 521990, upload-time = "2025-11-24T23:25:33.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/b4/9fbb4b0af4e36d96a61d026dd37acab3cf521a70290a09640b215da5ab7c/asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540", size = 581629, upload-time = "2025-11-24T23:25:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" }, + { url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" }, + { url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" }, + { url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" }, + { url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" }, + { url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + +[[package]] +name = "base58" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/45/8ae61209bb9015f516102fa559a2914178da1d5868428bd86a1b4421141d/base58-2.1.1.tar.gz", hash = "sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c", size = 6528, upload-time = "2021-10-30T22:12:17.858Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/45/ec96b29162a402fc4c1c5512d114d7b3787b9d1c2ec241d9568b4816ee23/base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2", size = 5621, upload-time = "2021-10-30T22:12:16.658Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", size = 183837, upload-time = "2026-07-06T21:32:09.655Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", size = 184226, upload-time = "2026-07-06T21:32:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", size = 211107, upload-time = "2026-07-06T21:32:12.328Z" }, + { url = "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", size = 218733, upload-time = "2026-07-06T21:32:13.67Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", size = 205543, upload-time = "2026-07-06T21:32:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", size = 205460, upload-time = "2026-07-06T21:32:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", size = 218760, upload-time = "2026-07-06T21:32:17.98Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", size = 221230, upload-time = "2026-07-06T21:32:19.407Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", size = 213524, upload-time = "2026-07-06T21:32:20.828Z" }, + { url = "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", size = 220341, upload-time = "2026-07-06T21:32:21.9Z" }, + { url = "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", size = 174578, upload-time = "2026-07-06T21:32:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", size = 185071, upload-time = "2026-07-06T21:32:24.671Z" }, + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[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 = "dva-vc-manager" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "asyncpg" }, + { name = "base58" }, + { name = "fastapi" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pynacl" }, + { name = "structlog" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [ + { name = "asyncpg", specifier = ">=0.30.0" }, + { name = "base58", specifier = ">=2.1.1" }, + { name = "fastapi", specifier = "~=0.136.3" }, + { name = "pydantic", specifier = ">=2.10.6" }, + { name = "pydantic-settings", specifier = ">=2.5.0" }, + { name = "pynacl", specifier = ">=1.5.0" }, + { name = "structlog", specifier = ">=25.1.0" }, + { name = "uvicorn", specifier = ">=0.34.3" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27.0" }, + { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-asyncio", specifier = ">=0.24.0" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +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 = "fastapi" +version = "0.136.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, +] + +[[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.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[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 = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "structlog" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/89/b4a0bcfdf4f71a3dea31379f095929613d7e4528a0996bca6aa964cd0dca/structlog-26.1.0.tar.gz", hash = "sha256:f63a716cbd1b1291cf7661de7794b455acfa4c43c5bcf1630e6ad5ddc1adb3b7", size = 1459881, upload-time = "2026-06-06T07:33:39.348Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/18/489c97b834dfff9cf2fc2507cede4bcd4b11e67f84bc462acd1992496f86/structlog-26.1.0-py3-none-any.whl", hash = "sha256:e081a26d6c373e6d201eca24eede26d8ffab07f88f477822e679183428d3d91e", size = 73764, upload-time = "2026-06-06T07:33:38.046Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[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 = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.51.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" }, +] From 22ed5721cc0a7cff2cf90a31197b3bb9dcfa6196 Mon Sep 17 00:00:00 2001 From: Yassine Rhouma Date: Mon, 27 Jul 2026 13:02:39 +0200 Subject: [PATCH 02/22] feat(vc): verify whitelist before payload decode and handle signature tampering --- dva-vc-manager/src/dva_vc_manager/routes.py | 41 ++++++++++++--------- dva-vc-manager/tests/test_routes.py | 6 +-- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/dva-vc-manager/src/dva_vc_manager/routes.py b/dva-vc-manager/src/dva_vc_manager/routes.py index f1a4a80a..7a40edbf 100644 --- a/dva-vc-manager/src/dva_vc_manager/routes.py +++ b/dva-vc-manager/src/dva_vc_manager/routes.py @@ -94,20 +94,17 @@ async def aov_verify( """Verify an AoV JWS. The issuer did:key is extracted from the JWS payload and looked up in the whitelist. Fail-closed.""" - # 1. Decode the JWS payload to extract the issuer did:key. - try: - payload = decode_payload(req.jws) - except Exception as e: + # 1. Structural check: must be a 3-part compact JWS. + parts = req.jws.split(".") + if len(parts) != 3: raise HTTPException( status.HTTP_400_BAD_REQUEST, - detail=f"malformed JWS: {e}", + detail="Compact JWS must have 3 dot-separated parts", ) - issuer_did_key = payload.get("issuer") - if not issuer_did_key: - return AovVerifyResponse(verified=False, reason="JWS payload missing issuer") - - # 2. Whitelist must be non-empty. + # 2. Whitelist must be non-empty. Checked early so an operator who + # has not configured any trusted issuers gets a clear reason rather + # than a payload-decode error. entries = await whitelist.all() if not entries: return AovVerifyResponse( @@ -115,12 +112,24 @@ async def aov_verify( reason="whitelist is not configured; verification is disabled", ) - # 3. Issuer must be whitelisted — fail-closed when not found. + # 3. Decode the JWS payload to extract the issuer did:key. A payload + # that is structurally a JWS but cannot be decoded (e.g. tampered) + # is reported as a verification failure, not a 400. + try: + payload = decode_payload(req.jws) + except Exception as e: + return AovVerifyResponse(verified=False, reason=f"malformed JWS payload: {e}") + + issuer_did_key = payload.get("issuer") + if not isinstance(issuer_did_key, str) or not issuer_did_key: + return AovVerifyResponse(verified=False, reason="JWS payload missing issuer") + + # 4. Issuer must be whitelisted — fail-closed when not found. entry = await whitelist.find(issuer_did_key) if entry is None: return AovVerifyResponse(verified=False, reason="issuer not whitelisted") - # 4. Derive the public key from the whitelist record's did:key. + # 5. Derive the public key from the whitelist record's did:key. try: public_key = did_key_to_public_key(entry.did_key) except Exception as e: @@ -129,16 +138,14 @@ async def aov_verify( reason=f"whitelist entry contains invalid did:key: {e}", ) - # 5. Verify the Ed25519 signature. + # 6. Verify the Ed25519 signature. A structurally-valid JWS whose + # signature does not verify returns verified=false. from nacl.signing import VerifyKey try: ok = verify_jws(req.jws, VerifyKey(bytes(public_key))) except Exception as e: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail=f"malformed JWS: {e}", - ) + return AovVerifyResponse(verified=False, reason=f"signature check failed: {e}") if not ok: return AovVerifyResponse(verified=False, reason="signature mismatch") diff --git a/dva-vc-manager/tests/test_routes.py b/dva-vc-manager/tests/test_routes.py index ca0c233a..684afae3 100644 --- a/dva-vc-manager/tests/test_routes.py +++ b/dva-vc-manager/tests/test_routes.py @@ -103,9 +103,9 @@ async def test_aov_verify_rejects_tampered_jws(client: TestClient, whitelist: Fa await whitelist.add(issuer_did_key) parts = jws.split(".") - first_char = parts[1][0] + first_char = parts[2][0] flipped = "B" if first_char == "A" else "A" - parts[1] = flipped + parts[1][1:] + parts[2] = flipped + parts[2][1:] tampered = f"{parts[0]}.{parts[1]}.{parts[2]}" r2 = client.post("/aov/verify", json={"jws": tampered}) @@ -140,7 +140,7 @@ def test_aov_verify_rejects_malformed_jws_with_400( client: TestClient, whitelist: FakeWhitelist ) -> None: import asyncio - asyncio.get_event_loop().run_until_complete(whitelist.add(_KNOWN_DID_KEY)) + asyncio.run(whitelist.add(_KNOWN_DID_KEY)) r = client.post("/aov/verify", json={"jws": "not.a.jws.at.all"}) assert r.status_code == 400 From a6aaf07b85a6f2eac85bc79e9653e8787601a848 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Fri, 31 Jul 2026 11:47:21 +0200 Subject: [PATCH 03/22] docs(vc): improve VC Manager API spec --- docs/spec/dva-vc-manager.yaml | 203 ++++++++++++++-------------------- 1 file changed, 86 insertions(+), 117 deletions(-) diff --git a/docs/spec/dva-vc-manager.yaml b/docs/spec/dva-vc-manager.yaml index b72ab016..4b9cda9b 100644 --- a/docs/spec/dva-vc-manager.yaml +++ b/docs/spec/dva-vc-manager.yaml @@ -1,41 +1,35 @@ +--- openapi: 3.1.0 -info: - title: DVA VC Manager - version: 0.3.0 - description: |- - Issues and verifies Attestations of Veracity (AoV) as W3C VC 2.0 JSON - Web Signatures (Ed25519/EdDSA via PyNaCl). - Verification is fail-closed: a JWS is only accepted when its issuer - did:key is present in the local whitelist. An empty whitelist rejects - every verification; an untrusted issuer is rejected. - Wire casing: AoV routes use camelCase to match the DVA API orchestrator; - admin routes use snake_case. +info: + title: DVA VC Manager + version: 0.1.0 + description: >- + Issues and verifies Attestations of Veracity (AoV) + as W3C VC 2.0 JSON Web Signatures (Ed25519). + Also maintains a list of trusted DIDs – AoV verification rejects + untrusted (non-whitelisted) issuers. contact: email: bpeter@edu.bme.hu + + servers: - url: http://localhost:8001 description: Provider - url: http://localhost:8002 description: Consumer -tags: - - name: AoV Issue - description: Issue an AoV JWS credential (Ed25519/EdDSA via PyNaCl). Open — no auth. - - name: AoV Verify - description: Verify an AoV JWS credential against the fail-closed did:key whitelist. Open — no auth. - - name: Admin - description: Manage the did:key whitelist and inspect this issuer's own signing key. Requires a bearer API key. + + paths: /aov/issue: post: - tags: [AoV Issue] summary: Issue an AoV JWS credential - description: | + description: >- Signs the supplied AoV payload as a W3C VC 2.0 JSON-LD JWS using the - service's persisted Ed25519 private key (EdDSA). The returned `jws` - is a compact `header.payload.signature` string. A UUID for the - credential is generated server-side and embedded in the JWS payload. + service's persisted Ed25519 private key. The returned `jws` is a + compact `header.payload.signature` string. A UUID for the credential + is generated server-side and embedded in the JWS payload. operationId: issueAov requestBody: required: true @@ -74,14 +68,11 @@ paths: $ref: '#/components/schemas/Error' /aov/verify: post: - tags: [AoV Verify] summary: Verify an AoV JWS credential - description: | + description: >- Verifies the Ed25519 signature of a compact AoV JWS. The issuer - did:key is extracted from the JWS payload and looked up in the - local whitelist. Fail-closed: when the whitelist is empty or the - issuer is not registered, `verified` is false with a descriptive - `reason`. A malformed JWS returns 400. + DID is extracted from the JWS payload and looked up in the + local whitelist. operationId: verifyAov requestBody: required: true @@ -93,7 +84,7 @@ paths: VerifyJws: summary: Verify a previously issued AoV JWS value: - jws: 'eyJhbGciOiJFZERTQSIsInR5cCI6IlZDK0xELUpTT04rSldTIn0...' + jws: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c responses: '200': description: Verification outcome. @@ -111,20 +102,16 @@ paths: value: verified: false reason: issuer not whitelisted - '400': - description: Malformed JWS (not a valid 3-part compact JWS). + '422': + description: Malformed JWS content: application/json: schema: $ref: '#/components/schemas/Error' /admin/whitelist: get: - tags: [Admin] - summary: List all whitelisted did:keys - description: | - Returns the current did:key whitelist as an array. - - **Wire casing: snake_case** (`id`, `did_key`, `label`). + summary: List all whitelisted DIDs + description: Returns the current DID whitelist as an array. operationId: listWhitelist responses: '200': @@ -135,20 +122,11 @@ paths: type: array items: $ref: '#/components/schemas/WhitelistEntry' - '401': - description: Missing or invalid bearer API key. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' post: - tags: [Admin] - summary: Add a did:key to the whitelist + summary: Add a DID to the whitelist description: | Registers a new trusted attester `did:key`. Returns `201` with the created entry on success. Duplicate `did_key` values return `400`. - - **Wire casing: snake_case** (`did_key`, `label`). operationId: addWhitelistEntry requestBody: required: true @@ -161,7 +139,7 @@ paths: summary: Whitelist an issuer did:key value: did_key: did:key:z6MktRz8iVwNh1rLKV47C2i2nMe4zwGt7SgLBjS9zw1jNuQY - label: DVA VC Manager (provider) + label: known-provider-1 responses: '201': description: Entry created. @@ -169,26 +147,26 @@ paths: application/json: schema: $ref: '#/components/schemas/WhitelistEntry' - '400': - description: Invalid did:key or duplicate entry. + '409': + description: Duplicate entry. content: application/json: schema: $ref: '#/components/schemas/Error' - '401': - description: Missing or invalid bearer API key. + '422': + description: Invalid DID content: application/json: schema: $ref: '#/components/schemas/Error' /admin/whitelist/{did_key}: delete: - tags: [Admin] - summary: Remove a did:key from the whitelist - description: | + summary: Remove a DID from the whitelist + description: >- Deletes the whitelist entry whose `did_key` matches the URL-encoded path parameter. Returns `204` on success, `404` if not found. + The `{did_key}` path parameter is the percent-encoded `did:key:...` identifier — colon (`:`) must be encoded as `%3A` per RFC 3986. operationId: removeWhitelistEntry @@ -196,19 +174,15 @@ paths: - name: did_key in: path required: true - description: URL-encoded did:key identifier to remove (e.g. `did%3Akey%3Az6Mk...`). + description: >- + URL-encoded did:key identifier to remove + (e.g. `did%3Akey%3Az6Mk...`). schema: type: string example: did%3Akey%3Az6Mku8XYifPt5tfL93VpJhFWuoyQDt6bTRqfWestrpo6YM5d responses: '204': description: Entry removed. - '401': - description: Missing or invalid bearer API key. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' '404': description: did:key not in whitelist. content: @@ -217,37 +191,21 @@ paths: $ref: '#/components/schemas/Error' /admin/keys: get: - tags: [Admin] - summary: Get this issuer's own did:key - description: | - Returns the `did:key` identifier derived from the service's persisted - Ed25519 signing key. Read-only — no private bytes are ever exposed. - - **Wire casing: snake_case** (`issuer_did_key`, `key_path`). + summary: Get this issuer's own DID + description: >- + Returns the DID derived from the service's persisted Ed25519 + signing key. operationId: getOwnKey responses: '200': - description: Issuer's own did:key and persisted key location. + description: Issuer's own DID and persisted key location. content: application/json: schema: $ref: '#/components/schemas/OwnKey' - '401': - description: Missing or invalid bearer API key. - content: - application/json: - schema: - $ref: '#/components/schemas/Error' + + components: - securitySchemes: - ApiKey: - type: apiKey - in: header - name: Authorization - description: |- - Bearer API key. Send as `Authorization: Bearer `. - The service fails closed (`401`) for all `/admin/*` routes when the - `DVA_VC_MANAGER_API_KEY` environment variable is unset. schemas: QualityEngine: type: string @@ -255,12 +213,9 @@ components: description: The evaluation engine that produced a veracity result. EvaluationResult: type: object - description: |- - Outcome of one VLA requirement evaluation, embedded in `AovIssueRequest. - evaluationResults`. - - **Wire casing: camelCase** (`engine`, `timestamp`, `success`, - `details`, `error`). + description: >- + Outcome of one VLA requirement evaluation, embedded in + `AovIssueRequest.evaluationResults`. required: [timestamp, success] additionalProperties: false properties: @@ -276,11 +231,13 @@ components: details: type: string nullable: true - description: Human-readable success explanation when `success` is true. + description: >- + Human-readable success explanation when `success` is true. error: type: string nullable: true - description: Human-readable failure explanation when `success` is false. + description: >- + Human-readable failure explanation when `success` is false. example: engine: JQ timestamp: '2026-01-31T17:48:10.904264Z' @@ -288,9 +245,17 @@ components: details: Actor name is correct AovIssueRequest: type: object - description: Body of POST /aov/issue. Wire casing: camelCase. + description: Body of POST /aov/issue. additionalProperties: false - required: [validSince, subject, issuerId, recordId, contractId, dataExchangeId, payload, evaluationResults] + required: + - validSince + - subject + - issuerId + - recordId + - contractId + - dataExchangeId + - payload + - evaluationResults properties: validSince: type: string @@ -301,7 +266,8 @@ components: description: The credential subject's identifier (e.g. a did:web). issuerId: type: string - description: The logical issuer identifier (separate from the did:key). + description: >- + The logical issuer identifier (separate from the did:key). recordId: type: string description: External record identifier being attested. @@ -313,7 +279,8 @@ components: description: The data-exchange identifier this AoV covers. payload: type: string - description: The opaque payload (stringified JSON) to sign as the JWS payload. + description: >- + The opaque payload (stringified JSON) to sign as the JWS payload. evaluationResults: type: array items: @@ -321,22 +288,22 @@ components: description: The VLA evaluation results carried inside the AoV. AovIssueResponse: type: object - description: Response of POST /aov/issue. Wire casing: camelCase. + description: Response of POST /aov/issue. additionalProperties: false required: [jws] properties: jws: type: string - description: Compact JWS header.payload.signature (Ed25519/EdDSA). + description: Compact JWS header.payload.signature (Ed25519). AovVerifyRequest: type: object - description: Body of POST /aov/verify. Wire casing: camelCase. + description: Body of POST /aov/verify. additionalProperties: false required: [jws] properties: jws: type: string - description: Compact JWS to verify (header.payload.signature). + description: Compact JWS to verify (`header.payload.signature`). AovVerifyResponse: type: object description: Response of POST /aov/verify. @@ -345,16 +312,17 @@ components: properties: verified: type: boolean - description: Whether the JWS is well-formed, correctly signed, and the issuer is whitelisted. + description: >- + Whether the JWS is well-formed, correctly signed, and the issuer + is whitelisted. reason: type: string nullable: true - description: Present when verified is false. Explains why verification failed. + description: >- + Present when verified is false. Explains why verification failed. WhitelistEntry: type: object - description: |- - A single whitelist entry. **Wire casing: snake_case** (`id`, `did_key`, - `label`). + description: A single whitelist entry. additionalProperties: false required: [id, did_key] properties: @@ -364,16 +332,15 @@ components: description: Internal UUID assigned to this whitelist entry. did_key: type: string - description: The attester's `did:key` identifier (e.g. `did:key:z6Mk...`). + description: >- + The attester's `did:key` identifier (e.g. `did:key:z6Mk...`). label: type: string nullable: true description: Optional human-readable label for the attester. WhitelistAddRequest: type: object - description: |- - Body of `POST /admin/whitelist`. **Wire casing: snake_case** - (`did_key`, `label`). + description: Body of `POST /admin/whitelist`. additionalProperties: false required: [did_key] properties: @@ -386,19 +353,20 @@ components: description: Optional human-readable label. OwnKey: type: object - description: |- + description: >- This issuer's own did:key plus the persisted private-key location. - **Wire casing: snake_case** (`issuer_did_key`, `key_path`). Private - bytes are never exposed. additionalProperties: false required: [issuer_did_key, key_path] properties: issuer_did_key: type: string - description: The did:key identifier derived from this service's Ed25519 public key. + description: >- + The did:key identifier derived from this service's Ed25519 + public key. key_path: type: string - description: Filesystem path where the signing key is persisted (no private bytes). + description: >- + Filesystem path where the signing key is persisted. Error: type: object description: RFC 7807-style problem object emitted on all error responses. @@ -414,4 +382,5 @@ components: detail: type: string nullable: true - description: Longer human-readable explanation specific to this occurrence. \ No newline at end of file + description: >- + Longer human-readable explanation specific to this occurrence. From c2422364dbc52e9983c1dec1231264e9ab50edf7 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Fri, 31 Jul 2026 15:46:16 +0200 Subject: [PATCH 04/22] chore(vc): add ruff config and apply mechanical formatting --- dva-vc-manager/pyproject.toml | 21 ++++- dva-vc-manager/src/dva_vc_manager/__init__.py | 24 ++--- dva-vc-manager/src/dva_vc_manager/auth.py | 6 +- dva-vc-manager/src/dva_vc_manager/config.py | 20 ++-- .../src/dva_vc_manager/dependencies.py | 7 +- dva-vc-manager/src/dva_vc_manager/did_key.py | 23 +++-- dva-vc-manager/src/dva_vc_manager/keys.py | 20 ++-- dva-vc-manager/src/dva_vc_manager/main.py | 27 ++++-- dva-vc-manager/src/dva_vc_manager/models.py | 48 +++++----- dva-vc-manager/src/dva_vc_manager/routes.py | 39 +++++--- dva-vc-manager/src/dva_vc_manager/signing.py | 71 +++------------ .../src/dva_vc_manager/whitelist.py | 28 +++--- dva-vc-manager/tests/test_did_key.py | 4 +- dva-vc-manager/tests/test_jws.py | 4 +- dva-vc-manager/tests/test_keys.py | 4 +- dva-vc-manager/tests/test_routes.py | 18 ++-- dva-vc-manager/tests/test_whitelist.py | 2 +- dva-vc-manager/uv.lock | 91 +++++++++++++++++++ 18 files changed, 268 insertions(+), 189 deletions(-) diff --git a/dva-vc-manager/pyproject.toml b/dva-vc-manager/pyproject.toml index c2a2f1ee..ca84d2fe 100644 --- a/dva-vc-manager/pyproject.toml +++ b/dva-vc-manager/pyproject.toml @@ -29,7 +29,24 @@ build-backend = "hatchling.build" packages = ["src/dva_vc_manager"] [dependency-groups] -dev = ["pytest>=8.3.5", "httpx>=0.27.0", "pytest-asyncio>=0.24.0"] +dev = [ + "pytest>=8.3.5", + "httpx>=0.27.0", + "pytest-asyncio>=0.24.0", + "ruff>=0.14.0", +] [tool.pytest.ini_options] -asyncio_mode = "auto" \ No newline at end of file +asyncio_mode = "auto" + +[tool.ruff] +target-version = "py310" + +[tool.ruff.lint] +# Conservative starting set: pyflakes (F), the pycodestyle checks ruff enables +# by default (E4 imports, E7 statements, E9 syntax), and isort (I) so import +# blocks are ordered and grouped mechanically rather than by hand. +select = ["E4", "E7", "E9", "F", "I"] + +[tool.ruff.lint.isort] +known-first-party = ["dva_vc_manager"] diff --git a/dva-vc-manager/src/dva_vc_manager/__init__.py b/dva-vc-manager/src/dva_vc_manager/__init__.py index 680b7fca..468323c3 100644 --- a/dva-vc-manager/src/dva_vc_manager/__init__.py +++ b/dva-vc-manager/src/dva_vc_manager/__init__.py @@ -1,19 +1,15 @@ -"""DVA Verifiable Credential Manager. +""" +DVA Verifiable Credential Manager. -Hosted at each Participant. Owns: +A decentralized internal DVA service, hosted at each participant. -* The Ed25519 signing key for that participant (loaded from a file or - generated on first boot via PyNaCl — never a hand-rolled crypto - primitive). -* The ``did:key`` whitelist of trusted attesters (used by the verify - side). -* The W3C VC 2.0 JSON-LD payload shape used for the Attestation of - Veracity (AoV) — produced and consumed verbatim, never mutated. +Owns: -The service exposes ``POST /aov/issue`` (called by the DVA API during -credential issuance in the synchronous attestation flow) and -``POST /aov/verify`` (called by the DVA API during the consumer-side -verification flow). +* The Ed25519 signing key for that participant (loaded from a file or generated + on first boot) +* The ``did:key`` whitelist of trusted attesters (consulted during verification) +* The W3C VC 2.0 JSON-LD payload shape used for the Attestation of + Veracity (AoV) """ -__version__ = "0.1.0" \ No newline at end of file +__version__ = "0.1.0" diff --git a/dva-vc-manager/src/dva_vc_manager/auth.py b/dva-vc-manager/src/dva_vc_manager/auth.py index 733aee24..f98799d3 100644 --- a/dva-vc-manager/src/dva_vc_manager/auth.py +++ b/dva-vc-manager/src/dva_vc_manager/auth.py @@ -1,4 +1,5 @@ -"""Minimal Bearer-token auth for the admin endpoints. +""" +Minimal Bearer-token auth for the admin endpoints. Mirrors the inline guard the Kotlin ``route/adminRoutes.kt`` uses: when ``DVA_VC_MANAGER_API_KEY`` is empty, admin endpoints are disabled @@ -17,4 +18,5 @@ def require_api_key(authorization: str | None = Header(default=None)) -> None: raise HTTPException(status.HTTP_401_UNAUTHORIZED, "API key not configured") header = (authorization or "").removeprefix("Bearer ").strip() if header != cfg.api_key: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid API key") \ No newline at end of file + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid API key") + diff --git a/dva-vc-manager/src/dva_vc_manager/config.py b/dva-vc-manager/src/dva_vc_manager/config.py index 35e6a332..612476bb 100644 --- a/dva-vc-manager/src/dva_vc_manager/config.py +++ b/dva-vc-manager/src/dva_vc_manager/config.py @@ -1,8 +1,4 @@ -"""Runtime configuration for the DVA VC Manager. - -Mirrors the ``vla_manager_api.config`` shape (plain dataclass + -envvars). -""" +"""Runtime configuration for the DVA VC Manager.""" from __future__ import annotations @@ -26,15 +22,17 @@ class Config: port: int = int(os.getenv("DVA_VC_MANAGER_PORT", "8000")) log_level: int = _log_level(os.getenv("DVA_VC_MANAGER_LOG_LEVEL", "INFO")) - # Ed25519 signing key file path. Loaded on first use; created and - # persisted (0600) if missing. Mirrors ``SigningKeyStore.kt:34``. - signing_key_path: str = os.getenv("DVA_VC_MANAGER_SIGNING_KEY_PATH", "/data/dva-vc-signing-key.pem") + # Ed25519 signing key file path. + # Loaded on first use; created and persisted (0600) if missing. + signing_key_path: str = os.getenv( + "DVA_VC_MANAGER_SIGNING_KEY_PATH", "/data/dva-vc-signing-key.pem" + ) # Optional shared-secret bearer auth for the admin endpoints. api_key: str = os.getenv("DVA_VC_MANAGER_API_KEY", "") - # Postgres DSN (whitelist). Required for the production (asyncpg) - # whitelist repo; empty → fall back to in-memory ``FakeWhitelist``. + # Postgres DSN (whitelist). Required for the production (asyncpg) + # whitelist repo; empty → fall back to in-memory FakeWhitelist. postgres_dsn: str = os.getenv("DVA_VC_MANAGER_DB_URL", "") @@ -54,4 +52,4 @@ def setup_logging() -> None: processors=processors, context_class=dict, wrapper_class=make_filtering_bound_logger(cfg.log_level), - ) \ No newline at end of file + ) diff --git a/dva-vc-manager/src/dva_vc_manager/dependencies.py b/dva-vc-manager/src/dva_vc_manager/dependencies.py index 2bd929f0..9c0c6a48 100644 --- a/dva-vc-manager/src/dva_vc_manager/dependencies.py +++ b/dva-vc-manager/src/dva_vc_manager/dependencies.py @@ -21,9 +21,7 @@ async def get_whitelist() -> WhitelistRepo: _whitelist_singleton = await _build_production_whitelist_async() else: logger.warning( - "DVA_VC_MANAGER_DB_URL is not set — falling back to " - "FakeWhitelist (in-memory). Verifications will fail-closed " - "until admin populates the whitelist via POST /admin/whitelist." + "DVA_VC_MANAGER_DB_URL is not set – falling back to in-memory FakeWhitelist" ) _whitelist_singleton = FakeWhitelist() return _whitelist_singleton @@ -31,4 +29,5 @@ async def get_whitelist() -> WhitelistRepo: def install_whitelist_for_tests(repo: WhitelistRepo) -> None: global _whitelist_singleton - _whitelist_singleton = repo \ No newline at end of file + _whitelist_singleton = repo + diff --git a/dva-vc-manager/src/dva_vc_manager/did_key.py b/dva-vc-manager/src/dva_vc_manager/did_key.py index 2c7b16f6..3af3af71 100644 --- a/dva-vc-manager/src/dva_vc_manager/did_key.py +++ b/dva-vc-manager/src/dva_vc_manager/did_key.py @@ -1,16 +1,13 @@ -"""``did:key`` codec for Ed25519 keys. - -Mirrors the Kotlin implementation in ``dva-api/api/.../jws/DidKey.kt``: +""" +``did:key`` codec for Ed25519 keys. * The Ed25519 public key is encoded as 32 raw bytes (RFC 8032). * Prefixed with the Ed25519 multicodec prefix ``0xed 0x01``. -* Then ``multibase(base58btc(...))`` — prefixed with the ``z`` character +* Then ``multibase(base58btc(...))`` – prefixed with the ``z`` character for base58btc. * Finally wrapped in ``did:key:``. -This is exactly the W3C did:key specification — no custom cryptography. -``base58`` is a tiny, audited Python package; ``nacl`` is the canonical -libsodium binding (PyNaCl). +This is exactly the W3C did:key specification – no custom cryptography. Reference: - https://w3c-ccg.github.io/did-method-key/ @@ -34,7 +31,11 @@ def public_key_to_did_key(public_key: PublicKey) -> str: if len(raw) != ED25519_RAW_SIZE: raise ValueError(f"Ed25519 public key must be exactly 32 bytes, got {len(raw)}") multicodec = ED25519_MULTICODEC_PREFIX + raw - return DID_KEY_SCHEME + MULTIBASE_BASE58BTC_PREFIX + base58.b58encode(multicodec).decode("ascii") + return ( + DID_KEY_SCHEME + + MULTIBASE_BASE58BTC_PREFIX + + base58.b58encode(multicodec).decode("ascii") + ) def did_key_to_public_key(did_key: str) -> PublicKey: @@ -43,7 +44,9 @@ def did_key_to_public_key(did_key: str) -> PublicKey: raise ValueError(f"not a did:key identifier: {did_key}") multibase = did_key.removeprefix(DID_KEY_SCHEME) if not multibase.startswith(MULTIBASE_BASE58BTC_PREFIX): - raise ValueError(f"only base58btc multibase ('z') is supported, got: {multibase}") + raise ValueError( + f"only base58btc multibase ('z') is supported, got: {multibase}" + ) decoded = base58.b58decode(multibase[1:]) if len(decoded) != len(ED25519_MULTICODEC_PREFIX) + ED25519_RAW_SIZE: raise ValueError( @@ -55,4 +58,4 @@ def did_key_to_public_key(did_key: str) -> PublicKey: f"multicodec prefix 0x{decoded[0]:02x}{decoded[1]:02x} " "is not the Ed25519 prefix 0xed01" ) - return PublicKey(decoded[2:]) \ No newline at end of file + return PublicKey(decoded[2:]) diff --git a/dva-vc-manager/src/dva_vc_manager/keys.py b/dva-vc-manager/src/dva_vc_manager/keys.py index 9e9df86e..a91758d7 100644 --- a/dva-vc-manager/src/dva_vc_manager/keys.py +++ b/dva-vc-manager/src/dva_vc_manager/keys.py @@ -1,11 +1,7 @@ -"""Ed25519 signing-key store. - -Mirrors the Kotlin ``SigningKeyStore.kt:34`` semantics **without** -implementing any custom cryptography — keys are generated and loaded via -PyNaCl's :class:`nacl.signing.SigningKey`, which is the canonical -libsodium binding (audited, used widely in production). +""" +Ed25519 signing-key store. -Persistence format: ``base64(private key seed)|base64(public key)`` — +Persistence format: ``base64(private key seed)|base64(public key)`` – PyNaCl exposes the private key as a 32-byte seed, the public key as 32 bytes. The seed is the canonical "private key" representation for Ed25519 in libsodium. @@ -19,11 +15,9 @@ import base64 import os from pathlib import Path -from typing import Tuple -from nacl.signing import SigningKey, VerifyKey from nacl.public import PublicKey - +from nacl.signing import SigningKey, VerifyKey __all__ = ["SigningKeyStore", "KeyPair"] @@ -106,5 +100,7 @@ def issuer_did_key(self) -> str: from .did_key import public_key_to_did_key if self._cached is None: - raise RuntimeError("SigningKeyStore.load_or_generate() must be called before issuer_did_key()") - return public_key_to_did_key(self._cached.public_key()) \ No newline at end of file + raise RuntimeError( + "SigningKeyStore.load_or_generate() must be called before issuer_did_key()" + ) + return public_key_to_did_key(self._cached.public_key()) diff --git a/dva-vc-manager/src/dva_vc_manager/main.py b/dva-vc-manager/src/dva_vc_manager/main.py index 7c66ed6b..eb2cda57 100644 --- a/dva-vc-manager/src/dva_vc_manager/main.py +++ b/dva-vc-manager/src/dva_vc_manager/main.py @@ -40,19 +40,23 @@ def _build_production_whitelist(): - """Construct the async-backed whitelist repository. + """ + Construct the async-backed whitelist repository. - DEPRECATED sync stub — kept only for test-override compatibility. + DEPRECATED sync stub – kept only for test-override compatibility. Production callers must use the async version below. """ - raise RuntimeError("Call _build_production_whitelist_async() from within an async context.") + raise RuntimeError( + "Call _build_production_whitelist_async() from within an async context." + ) async def _build_production_whitelist_async(): - """Construct the async-backed whitelist repository. + """ + Construct the async-backed whitelist repository. Uses await (not asyncio.run) so it is safe to call from within the - uvicorn event loop — mirrors _build_production_repo() in vla-manager-api. + uvicorn event loop. """ import asyncpg @@ -76,12 +80,12 @@ def create_app() -> FastAPI: title="DVA VC Manager", description=( "Issues and verifies Attestation of Veracity (AoV) credentials as " - "W3C VC 2.0 JSON-LD JWS (Ed25519/EdDSA) using PyNaCl. Hosted at " + "W3C VC 2.0 JSON-LD JWS (Ed25519) using PyNaCl. Hosted at " "each Participant. Called by the DVA API during credential " "issuance in the synchronous attestation flow." ), version="0.1.0", - # Disable auto-generated docs — hand-written spec is served at /swagger + # Disable auto-generated docs – hand-written spec is served at /swagger docs_url=None, redoc_url=None, openapi_url=None, @@ -94,7 +98,11 @@ async def swagger_ui() -> HTMLResponse: """Serve the Swagger UI loaded from the hand-written OpenAPI spec.""" return HTMLResponse(content=_SWAGGER_UI_HTML) - @app.get("/swagger/openapi.yaml", response_class=PlainTextResponse, include_in_schema=False) + @app.get( + "/swagger/openapi.yaml", + response_class=PlainTextResponse, + include_in_schema=False, + ) async def swagger_spec() -> PlainTextResponse: """Serve the hand-written OpenAPI spec YAML from disk.""" spec_path = os.environ.get("DVA_VC_MANAGER_OPENAPI_FILE", "/app/openapi.yaml") @@ -126,4 +134,5 @@ def cli() -> None: host=cfg.host, port=cfg.port, log_level=_level_to_str(cfg.log_level), - ) \ No newline at end of file + ) + diff --git a/dva-vc-manager/src/dva_vc_manager/models.py b/dva-vc-manager/src/dva_vc_manager/models.py index 235dc47c..19e18bf1 100644 --- a/dva-vc-manager/src/dva_vc_manager/models.py +++ b/dva-vc-manager/src/dva_vc_manager/models.py @@ -1,4 +1,5 @@ -"""HTTP request/response models for /aov/issue and /aov/verify. +""" +HTTP request/response models for /aov/issue and /aov/verify. The JSON shape is kept byte-compatible with the Kotlin ``dva-api`` ``/attestation`` response so the PDC client @@ -10,18 +11,17 @@ from __future__ import annotations from datetime import datetime -from typing import Any, Optional +from typing import Optional from uuid import UUID from pydantic import BaseModel, ConfigDict, Field - # JSON keys for these request/response models are byte-identical with the # Kotlin ``dva-api`` DTOs (``route/aovRoutes.kt:39-57`` and -# ``AoVDTOs.kt``). Kotlin property names are ``camelCase`` (e.g. ``vcId``, +# ``AoVDTOs.kt``). Kotlin property names are ``camelCase`` (e.g. ``vcId``, # ``issuerDidKey``); pydantic's Python-native attribute names stay # ``snake_case`` but the wire format MUST be ``camelCase`` so the Kotlin -# client/server round-trip works without contract drift. We add aliases +# client/server round-trip works without contract drift. We add aliases # to each advanced-name field and let ``populate_by_name=True`` keep # snake_case accepted on the Python side (for unit tests and direct # curl). @@ -29,11 +29,7 @@ class EvaluationResultDTO(BaseModel): - """One row of the veracity-check results array. - - Field names are single words so no aliasing is needed — they are - already byte-identical with the Kotlin ``EvaluationResultDTO``. - """ + """One row of the veracity-check results array.""" engine: Optional[str] = None timestamp: datetime @@ -43,12 +39,12 @@ class EvaluationResultDTO(BaseModel): class AovIssueRequest(BaseModel): - """Body of ``POST /aov/issue``. + """ + Body of ``POST /aov/issue``. - The DVA API posts the eight claims fields and the veracity-check - results array. The VC Manager decides whether to issue based on - ``all_success`` (computed here) and generates a fresh UUID for - the credential. + The DVA API posts the seven AoV claims fields plus the veracity-check + results array. The VC Manager generates a fresh UUID for the credential and + signs the AoV payload as a compact JWS. """ model_config = _CAMEL @@ -66,9 +62,11 @@ class AovIssueRequest(BaseModel): class AovIssueResponse(BaseModel): - """Returned by ``POST /aov/issue``. The JWS contains the issuer - ``did:key``, the VC UUID, and the issuance timestamp, so they are - not duplicated in the response body. + """ + Returned by ``POST /aov/issue``. + + The JWS contains the issuer ``did:key``, the VC UUID, and the issuance + timestamp, so they are not duplicated in the response body. """ model_config = _CAMEL @@ -77,8 +75,11 @@ class AovIssueResponse(BaseModel): class AovVerifyRequest(BaseModel): - """Body of ``POST /aov/verify``. Only the compact JWS string is - supplied; the issuer ``did:key`` is extracted from the JWS payload. + """ + Body of ``POST /aov/verify``. + + Only the compact JWS string is supplied; the issuer ``did:key`` is + extracted from the JWS payload. """ model_config = _CAMEL @@ -109,9 +110,10 @@ class WhitelistEntryDTO(BaseModel): class OwnKeyDTO(BaseModel): - """Returned by ``GET /admin/keys`` — read-only view of this - service's signing ``did:key`` (no private bytes). + """Returned by ``GET /admin/keys``. + + Read-only view of this service's signing ``did:key``. """ issuer_did_key: str - key_path: str \ No newline at end of file + key_path: str diff --git a/dva-vc-manager/src/dva_vc_manager/routes.py b/dva-vc-manager/src/dva_vc_manager/routes.py index 7a40edbf..1cbc9809 100644 --- a/dva-vc-manager/src/dva_vc_manager/routes.py +++ b/dva-vc-manager/src/dva_vc_manager/routes.py @@ -1,4 +1,5 @@ -"""FastAPI routes for the DVA VC Manager. +""" +FastAPI routes for the DVA VC Manager. Two AoV endpoints called by the DVA API during the synchronous attestation flow: @@ -25,7 +26,6 @@ from __future__ import annotations import urllib.parse -from datetime import datetime, timezone from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, status @@ -50,7 +50,8 @@ def _get_key_store() -> SigningKeyStore: - """Lazily construct the app-wide signing key store. + """ + Lazily construct the app-wide signing key store. Resolved via FastAPI's dependency system in tests via ``app.dependency_overrides`` so the test suite can swap in a @@ -68,10 +69,9 @@ async def aov_issue(req: AovIssueRequest) -> AovIssueResponse: keypair = key_store.load_or_generate() issuer_did_key = key_store.issuer_did_key() - # Mapped to the camelCase AovClaims fields — AovClaims VC-subject - # JSON keys must stay byte-identical with the Kotlin issuer, so - # we hand the model the snake_case values and rely on the field - # aliases in build_aov_payload. + # AoVClaims is passed in snake_case and build_aov_payload embeds these + # values into the VC JSON-LD payload (credentialSubject keys are snake_case + # to match the existing Kotlin implementation) claims = AovClaims( vc_id=str(uuid4()), valid_since=req.valid_since, @@ -91,8 +91,12 @@ async def aov_verify( req: AovVerifyRequest, whitelist: WhitelistRepo = Depends(get_whitelist), ) -> AovVerifyResponse: - """Verify an AoV JWS. The issuer did:key is extracted from the JWS - payload and looked up in the whitelist. Fail-closed.""" + """ + Verify an AoV JWS. + + The issuer did:key is extracted from the JWS + payload and looked up in the whitelist. + """ # 1. Structural check: must be a 3-part compact JWS. parts = req.jws.split(".") @@ -124,7 +128,7 @@ async def aov_verify( if not isinstance(issuer_did_key, str) or not issuer_did_key: return AovVerifyResponse(verified=False, reason="JWS payload missing issuer") - # 4. Issuer must be whitelisted — fail-closed when not found. + # 4. Issuer must be whitelisted entry = await whitelist.find(issuer_did_key) if entry is None: return AovVerifyResponse(verified=False, reason="issuer not whitelisted") @@ -138,7 +142,7 @@ async def aov_verify( reason=f"whitelist entry contains invalid did:key: {e}", ) - # 6. Verify the Ed25519 signature. A structurally-valid JWS whose + # 6. Verify the Ed25519 signature. A structurally-valid JWS whose # signature does not verify returns verified=false. from nacl.signing import VerifyKey @@ -165,7 +169,9 @@ async def whitelist_list( whitelist: WhitelistRepo = Depends(get_whitelist), ) -> list[WhitelistEntryDTO]: entries = await whitelist.all() - return [WhitelistEntryDTO(id=e.id, did_key=e.did_key, label=e.label) for e in entries] + return [ + WhitelistEntryDTO(id=e.id, did_key=e.did_key, label=e.label) for e in entries + ] @admin_router.post( @@ -182,7 +188,9 @@ async def whitelist_add( return WhitelistEntryDTO(id=entry.id, did_key=entry.did_key, label=entry.label) -@admin_router.delete("/admin/whitelist/{did_key}", status_code=status.HTTP_204_NO_CONTENT) +@admin_router.delete( + "/admin/whitelist/{did_key}", status_code=status.HTTP_204_NO_CONTENT +) async def whitelist_remove( did_key: str, _: None = Depends(require_api_key), @@ -202,4 +210,7 @@ async def keys_view(_: None = Depends(require_api_key)) -> OwnKeyDTO: key_store = SigningKeyStore(cfg.signing_key_path) key_store.load_or_generate() - return OwnKeyDTO(issuer_did_key=key_store.issuer_did_key(), key_path=cfg.signing_key_path) \ No newline at end of file + return OwnKeyDTO( + issuer_did_key=key_store.issuer_did_key(), key_path=cfg.signing_key_path + ) + diff --git a/dva-vc-manager/src/dva_vc_manager/signing.py b/dva-vc-manager/src/dva_vc_manager/signing.py index 57124d1e..4b63aeb9 100644 --- a/dva-vc-manager/src/dva_vc_manager/signing.py +++ b/dva-vc-manager/src/dva_vc_manager/signing.py @@ -1,20 +1,8 @@ -"""JWS issuance and verification. - -Muliberates the production of a compact JWS (``header.payload.signature``) -over a W3C VC 2.0 JSON-LD payload. The signed JSON shape is -**byte-for-byte identical** to the Kotlin ``JwsSigner.kt:39-57`` so any -existing consumer of an AoV JWS (PDC, other DVAs, downstream wallets) -can verify a Python-issued credential with the Kotlin verifier and -vice-versa. - -The cryptography itself is delegated entirely to PyNaCl (libsodium): - -* :func:`nacl.signing.SigningKey.sign` for EdDSA signatures. -* :func:`nacl.signing.VerifyKey.verify` for EdDSA verification. +""" +JWS issuance and verification. -No hand-rolled cryptography anywhere. Only the JSON shape construction, -base64url encoding, and the standard JWS compact serialization -concatenation happen here. +Facilitates the production of a compact JWS (``header.payload.signature``) +over a W3C VC 2.0 JSON-LD payload. """ from __future__ import annotations @@ -28,7 +16,7 @@ from .did_key import did_key_to_public_key -# JWS header constants — must match Kotlin ``JwsSigner.kt:30-34`` exactly. +# JWS header constants JWS_HEADER_ALG = "EdDSA" JWS_HEADER_TYPE = "VC+LD-JSON+JWS" VC_CONTEXT = "https://www.w3.org/2018/credentials/v1" @@ -42,30 +30,18 @@ def _b64url(data: bytes) -> str: def _b64url_decode(segment: str) -> bytes: - """Inverse of :func:`_b64url` — re-adds padding before decoding.""" + """Inverse of :func:`_b64url` – re-adds padding before decoding.""" pad = (-len(segment)) % 4 return base64.urlsafe_b64decode(segment + "=" * pad) def _json_compact(obj: dict[str, Any]) -> bytes: - """Compact JSON encoding — must match Kotlin's - ``Json.encodeToString(JsonObject.serializer(), this)`` byte-for-byte. - Kotlin's default ``kotlinx.serialization.json.Json`` uses no extra - whitespace, separators are ``","`` and ``":"``, keys preserve insertion - order. We use ``json.dumps(..., separators=(",", ":"), ensure_ascii=False)`` - for an exact match. - """ + """Compact JSON encoding.""" return json.dumps(obj, separators=(",", ":"), ensure_ascii=False).encode("utf-8") def build_aov_payload(claims: "AovClaims", issuer_did_key: str) -> dict[str, Any]: - """Build the W3C VC 2.0 JSON-LD payload. - - Identical to Kotlin ``buildAovPayload`` (``JwsSigner.kt:39-57``): - ``@context``, ``type`` (a two-element array), ``issuer``, - ``validFrom``, and ``credentialSubject`` carrying the eight AoV - claims. - """ + """Build the W3C VC 2.0 JSON-LD payload.""" return { "@context": [VC_CONTEXT], "type": [VC_TYPE, AOV_TYPE], @@ -89,13 +65,7 @@ def _jws_header() -> dict[str, str]: def sign_jws(claims: "AovClaims", signing_key: SigningKey, issuer_did_key: str) -> str: - """Sign and produce a compact JWS string. - - ``signing_key`` is a :class:`nacl.signing.SigningKey` (Ed25519). - The signature is produced by libsodium via - ``signing_key.sign(signing_input).signature`` — which is the - canonical EdDSA primitive, not a hand-rolled signing function. - """ + """Sign and produce a compact JWS string.""" header_b64 = _b64url(_json_compact(_jws_header())) payload_b64 = _b64url(_json_compact(build_aov_payload(claims, issuer_did_key))) signing_input = f"{header_b64}.{payload_b64}".encode("ascii") @@ -108,14 +78,7 @@ def sign_jws(claims: "AovClaims", signing_key: SigningKey, issuer_did_key: str) def verify_jws(jws: str, public_key: VerifyKey) -> bool: - """Verify a compact JWS. - - Returns ``True`` if the signature is valid; ``False`` on signature - mismatch (mirrors ``JwsSigner.kt:100-113`` semantics — bad - signature returns false rather than throwing). Malformed JWS raises - an exception (also matches the Kotlin test at - ``JwsSignerTest.kt:69-77``). - """ + """Verify a compact JWS.""" parts = jws.split(".") if len(parts) != 3: raise ValueError("Compact JWS must have 3 dot-separated parts") @@ -131,7 +94,7 @@ def verify_jws(jws: str, public_key: VerifyKey) -> bool: def verify_jws_with_did_key(jws: str, did_key: str) -> bool: """Convenience: derive the Ed25519 public key from a did:key and verify.""" public_key = did_key_to_public_key(did_key) - # VerifyKey accepts the raw 32-byte encoding — same bytes that did_key + # VerifyKey accepts the raw 32-byte encoding – same bytes that did_key # just decoded for us. return verify_jws(jws, VerifyKey(bytes(public_key))) @@ -144,7 +107,7 @@ def decode_payload(jws: str) -> dict[str, Any]: return json.loads(_b64url_decode(parts[1])) -# AoV claims model — defined at the bottom of the module so older +# AoV claims model – defined at the bottom of the module so older # pydantic-style annotations above ("AovClaims") resolve via forward # reference. Importing this class is the canonical way callers construct # the claims payload. @@ -152,13 +115,7 @@ def decode_payload(jws: str) -> dict[str, Any]: class AovClaims(BaseModel): - """The eight AoV credentialSubject claims. - - Fields are byte-identical to ``hu.bme.mit.ftsrg.dva.api.jws.AovClaims`` - (``JwsSigner.kt:19-28``): ``vcId, validSince, subject, issuerId, - recordId, contractId, dataExchangeId, payload``. Python field names - are snake_case but Pydantic aliases make the JSON keys camelCase. - """ + """The eight AoV credentialSubject claims.""" vc_id: str valid_since: str @@ -173,4 +130,4 @@ class AovClaims(BaseModel): @property def vcId(self) -> str: # noqa: N802 — parity with Kotlin property name. - return self.vc_id \ No newline at end of file + return self.vc_id diff --git a/dva-vc-manager/src/dva_vc_manager/whitelist.py b/dva-vc-manager/src/dva_vc_manager/whitelist.py index 258c7def..a557458d 100644 --- a/dva-vc-manager/src/dva_vc_manager/whitelist.py +++ b/dva-vc-manager/src/dva_vc_manager/whitelist.py @@ -1,13 +1,9 @@ -"""Whitelist of trusted attester ``did:key`` identifiers. - -The verify side is **fail-closed**: ``/aov/verify`` rejects any -verification when the whitelist is empty (mirrors -``aovRoutes.kt:231-242``). The whitelist is populated via admin -endpoints (``POST /admin/whitelist``) in the new DVA VC MANAGER service. +""" +Whitelist of trusted attester ``did:key`` identifiers. Two implementations: -* :class:`FakeWhitelist` — in-memory list for tests. -* :class:`PgWhitelist` — async-backed PostgreSQL repository via +* :class:`FakeWhitelist` – in-memory list for tests. +* :class:`PgWhitelist` – async-backed PostgreSQL repository via asyncpg; mirrors ``whitelistMapping.kt:17-20``. """ @@ -85,20 +81,20 @@ async def _ensure_schema(self) -> None: async def all(self) -> list[WhitelistEntry]: async with self._pool.acquire() as conn: - rows = await conn.fetch( - "SELECT id, did_key, label FROM did_key_whitelist" - ) + rows = await conn.fetch("SELECT id, did_key, label FROM did_key_whitelist") return [WhitelistEntry.from_row(r) for r in rows] async def add(self, did_key: str, label: Optional[str] = None) -> WhitelistEntry: - import json import asyncpg.exceptions - id = uuid4() + + entry_id = uuid4() async with self._pool.acquire() as conn: try: await conn.execute( "INSERT INTO did_key_whitelist (id, did_key, label) VALUES ($1, $2, $3)", - id, did_key, label, + entry_id, + did_key, + label, ) except asyncpg.exceptions.UniqueViolationError: existing = await conn.fetchrow( @@ -106,7 +102,7 @@ async def add(self, did_key: str, label: Optional[str] = None) -> WhitelistEntry did_key, ) return WhitelistEntry.from_row(existing) - return WhitelistEntry(id=id, did_key=did_key, label=label) + return WhitelistEntry(id=entry_id, did_key=did_key, label=label) async def remove(self, did_key: str) -> bool: async with self._pool.acquire() as conn: @@ -128,4 +124,4 @@ async def contains(self, did_key: str) -> bool: row = await conn.fetchrow( "SELECT 1 FROM did_key_whitelist WHERE did_key = $1", did_key ) - return row is not None \ No newline at end of file + return row is not None diff --git a/dva-vc-manager/tests/test_did_key.py b/dva-vc-manager/tests/test_did_key.py index c7cc8dd4..b7479179 100644 --- a/dva-vc-manager/tests/test_did_key.py +++ b/dva-vc-manager/tests/test_did_key.py @@ -2,8 +2,8 @@ from __future__ import annotations -from nacl.signing import SigningKey from nacl.public import PublicKey +from nacl.signing import SigningKey from dva_vc_manager.did_key import ( did_key_to_public_key, @@ -37,4 +37,4 @@ def test_starts_with_did_key_z6mk() -> None: did_key = public_key_to_did_key(pub) assert did_key.startswith("did:key:z6Mk"), ( "did:key identifier must start with 'did:key:z6Mk'" - ) \ No newline at end of file + ) diff --git a/dva-vc-manager/tests/test_jws.py b/dva-vc-manager/tests/test_jws.py index 29ffe06e..f2cc2935 100644 --- a/dva-vc-manager/tests/test_jws.py +++ b/dva-vc-manager/tests/test_jws.py @@ -64,7 +64,7 @@ def test_rejection_of_a_clearly_malformed_jws() -> None: def test_payload_shape_includes_context_type_issuer_validfrom_subject() -> None: """Validate the W3C VC JSON-LD structure — byte-identical to the Kotlin ``buildAovPayload`` at ``JwsSigner.kt:39-57``.""" - from dva_vc_manager.signing import build_aov_payload, decode_payload + from dva_vc_manager.signing import decode_payload signing_key = SigningKey.generate() jws = sign_jws(_sample_claims(), signing_key, _KNOWN_DID_KEY) @@ -81,4 +81,4 @@ def test_payload_shape_includes_context_type_issuer_validfrom_subject() -> None: assert sub["record_id"] == "rec-0001" assert sub["contract_id"] == "contract-0001" assert sub["data_exchange_id"] == "xchg-0001" - assert sub["payload"] == "checksum:sha256:abcdef0123456789" \ No newline at end of file + assert sub["payload"] == "checksum:sha256:abcdef0123456789" diff --git a/dva-vc-manager/tests/test_keys.py b/dva-vc-manager/tests/test_keys.py index db83c6e8..47ce4381 100644 --- a/dva-vc-manager/tests/test_keys.py +++ b/dva-vc-manager/tests/test_keys.py @@ -5,9 +5,7 @@ import os from pathlib import Path -from dva_vc_manager.did_key import public_key_to_did_key from dva_vc_manager.keys import SigningKeyStore -from nacl.public import PublicKey def test_generates_key_on_first_run_when_file_missing(tmp_path: Path) -> None: @@ -45,4 +43,4 @@ def test_derived_did_key_starts_with_z6mk(tmp_path: Path) -> None: store = SigningKeyStore(str(tmp_path / "key.pem")) store.load_or_generate() did_key = store.issuer_did_key() - assert did_key.startswith("did:key:z6Mk") \ No newline at end of file + assert did_key.startswith("did:key:z6Mk") diff --git a/dva-vc-manager/tests/test_routes.py b/dva-vc-manager/tests/test_routes.py index 684afae3..d034ee31 100644 --- a/dva-vc-manager/tests/test_routes.py +++ b/dva-vc-manager/tests/test_routes.py @@ -12,12 +12,8 @@ from __future__ import annotations -import os -from uuid import uuid4 - import pytest from fastapi.testclient import TestClient -from nacl.signing import SigningKey from dva_vc_manager.dependencies import get_whitelist from dva_vc_manager.main import create_app @@ -38,9 +34,12 @@ def whitelist() -> FakeWhitelist: @pytest.fixture -def client(whitelist: FakeWhitelist, monkeypatch: pytest.MonkeyPatch, tmp_path) -> TestClient: +def client( + whitelist: FakeWhitelist, monkeypatch: pytest.MonkeyPatch, tmp_path +) -> TestClient: monkeypatch.setenv("DVA_VC_MANAGER_SIGNING_KEY_PATH", str(tmp_path / "key.pem")) from dva_vc_manager import config as cfg_module + cfg_module.cfg.signing_key_path = str(tmp_path / "key.pem") cfg_module.cfg.api_key = "" cfg_module.cfg.postgres_dsn = "" @@ -81,7 +80,9 @@ def test_aov_issue_returns_jws(client: TestClient) -> None: assert issuer.startswith("did:key:z6Mk") -async def test_aov_issue_then_verify_round_trip(client: TestClient, whitelist: FakeWhitelist) -> None: +async def test_aov_issue_then_verify_round_trip( + client: TestClient, whitelist: FakeWhitelist +) -> None: r = client.post("/aov/issue", json=_issue_request()) assert r.status_code == 200 body = r.json() @@ -96,7 +97,9 @@ async def test_aov_issue_then_verify_round_trip(client: TestClient, whitelist: F assert body2["verified"] is True -async def test_aov_verify_rejects_tampered_jws(client: TestClient, whitelist: FakeWhitelist) -> None: +async def test_aov_verify_rejects_tampered_jws( + client: TestClient, whitelist: FakeWhitelist +) -> None: r = client.post("/aov/issue", json=_issue_request()) jws = r.json()["jws"] issuer_did_key = _extract_issuer_did_key(jws) @@ -140,6 +143,7 @@ def test_aov_verify_rejects_malformed_jws_with_400( client: TestClient, whitelist: FakeWhitelist ) -> None: import asyncio + asyncio.run(whitelist.add(_KNOWN_DID_KEY)) r = client.post("/aov/verify", json={"jws": "not.a.jws.at.all"}) assert r.status_code == 400 diff --git a/dva-vc-manager/tests/test_whitelist.py b/dva-vc-manager/tests/test_whitelist.py index 32b56c9b..bdff005f 100644 --- a/dva-vc-manager/tests/test_whitelist.py +++ b/dva-vc-manager/tests/test_whitelist.py @@ -58,4 +58,4 @@ async def test_contains_and_find(whitelist: FakeWhitelist) -> None: assert await whitelist.contains(_KNOWN_DID_KEY) is True found = await whitelist.find(_KNOWN_DID_KEY) assert found is not None - assert found.label == "x" \ No newline at end of file + assert found.label == "x" diff --git a/dva-vc-manager/uv.lock b/dva-vc-manager/uv.lock index ac1beb57..d931fb51 100644 --- a/dva-vc-manager/uv.lock +++ b/dva-vc-manager/uv.lock @@ -280,6 +280,7 @@ dev = [ { name = "httpx" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "ruff" }, ] [package.metadata] @@ -299,6 +300,7 @@ dev = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, + { name = "ruff", specifier = ">=0.14.0" }, ] [[package]] @@ -641,6 +643,95 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[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 = "ruff" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +] + [[package]] name = "starlette" version = "1.3.1" From 47bccce203c472cd4d0c79114c8d8906475b143c Mon Sep 17 00:00:00 2001 From: bzp99 Date: Fri, 31 Jul 2026 15:48:10 +0200 Subject: [PATCH 05/22] feat(vc): remove auth for now --- dva-vc-manager/README.md | 4 +--- dva-vc-manager/src/dva_vc_manager/auth.py | 22 --------------------- dva-vc-manager/src/dva_vc_manager/config.py | 3 --- dva-vc-manager/src/dva_vc_manager/routes.py | 9 ++------- dva-vc-manager/tests/test_routes.py | 11 ----------- 5 files changed, 3 insertions(+), 46 deletions(-) delete mode 100644 dva-vc-manager/src/dva_vc_manager/auth.py diff --git a/dva-vc-manager/README.md b/dva-vc-manager/README.md index cbbfbb85..2b9bab03 100644 --- a/dva-vc-manager/README.md +++ b/dva-vc-manager/README.md @@ -24,8 +24,6 @@ libsodium — no hand-rolled cryptography anywhere). | ``DELETE /admin/whitelist/{did_key}`` | Operator | Remove a trusted attester | | ``GET /admin/keys`` | Operator | View this service's own issuer ``did:key`` (read-only) | -All ``/admin/*`` endpoints require ``Authorization: Bearer ${DVA_VC_MANAGER_API_KEY}`` and -fail-closed with ``401`` when the key is empty. ## Cryptography libraries @@ -57,4 +55,4 @@ uv run dva-vc-manager # boot the service on :8000 | ``DVA_VC_MANAGER_DB_URL`` | *(empty)* | Postgres DSN for the whitelist. Empty → in-memory ``FakeWhitelist`` (verify path fails-closed until admin populates it). | | ``DVA_VC_MANAGER_API_KEY`` | *(empty)* | Shared-secret bearer for ``/admin/*``. When empty, admin endpoints are disabled. | | ``DVA_VC_MANAGER_PORT`` | ``8000`` | Listen port | -| ``DVA_VC_MANAGER_LOG_LEVEL`` | ``INFO`` | Standard Python log-level name | \ No newline at end of file +| ``DVA_VC_MANAGER_LOG_LEVEL`` | ``INFO`` | Standard Python log-level name | diff --git a/dva-vc-manager/src/dva_vc_manager/auth.py b/dva-vc-manager/src/dva_vc_manager/auth.py deleted file mode 100644 index f98799d3..00000000 --- a/dva-vc-manager/src/dva_vc_manager/auth.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -Minimal Bearer-token auth for the admin endpoints. - -Mirrors the inline guard the Kotlin ``route/adminRoutes.kt`` uses: when -``DVA_VC_MANAGER_API_KEY`` is empty, admin endpoints are disabled -entirely; otherwise require ``Authorization: Bearer ``. -""" - -from __future__ import annotations - -from fastapi import Header, HTTPException, status - -from .config import cfg - - -def require_api_key(authorization: str | None = Header(default=None)) -> None: - if cfg.api_key == "": - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "API key not configured") - header = (authorization or "").removeprefix("Bearer ").strip() - if header != cfg.api_key: - raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid API key") - diff --git a/dva-vc-manager/src/dva_vc_manager/config.py b/dva-vc-manager/src/dva_vc_manager/config.py index 612476bb..4147358f 100644 --- a/dva-vc-manager/src/dva_vc_manager/config.py +++ b/dva-vc-manager/src/dva_vc_manager/config.py @@ -28,9 +28,6 @@ class Config: "DVA_VC_MANAGER_SIGNING_KEY_PATH", "/data/dva-vc-signing-key.pem" ) - # Optional shared-secret bearer auth for the admin endpoints. - api_key: str = os.getenv("DVA_VC_MANAGER_API_KEY", "") - # Postgres DSN (whitelist). Required for the production (asyncpg) # whitelist repo; empty → fall back to in-memory FakeWhitelist. postgres_dsn: str = os.getenv("DVA_VC_MANAGER_DB_URL", "") diff --git a/dva-vc-manager/src/dva_vc_manager/routes.py b/dva-vc-manager/src/dva_vc_manager/routes.py index 1cbc9809..bff680f8 100644 --- a/dva-vc-manager/src/dva_vc_manager/routes.py +++ b/dva-vc-manager/src/dva_vc_manager/routes.py @@ -14,7 +14,7 @@ payload and looked up in the whitelist. Fail-closed: rejects if the whitelist is empty or the issuer is not registered. -Plus four admin endpoints (all bearer-auth-guarded): +Plus four admin endpoints: * ``GET /admin/whitelist`` — list trusted attesters. * ``POST /admin/whitelist`` — register a trusted attester's did:key. @@ -30,7 +30,6 @@ from fastapi import APIRouter, Depends, HTTPException, status -from .auth import require_api_key from .dependencies import get_whitelist from .did_key import did_key_to_public_key from .keys import SigningKeyStore @@ -165,7 +164,6 @@ async def aov_verify( @admin_router.get("/admin/whitelist", response_model=list[WhitelistEntryDTO]) async def whitelist_list( - _: None = Depends(require_api_key), whitelist: WhitelistRepo = Depends(get_whitelist), ) -> list[WhitelistEntryDTO]: entries = await whitelist.all() @@ -181,7 +179,6 @@ async def whitelist_list( ) async def whitelist_add( req: WhitelistAddRequest, - _: None = Depends(require_api_key), whitelist: WhitelistRepo = Depends(get_whitelist), ) -> WhitelistEntryDTO: entry = await whitelist.add(req.did_key, req.label) @@ -193,7 +190,6 @@ async def whitelist_add( ) async def whitelist_remove( did_key: str, - _: None = Depends(require_api_key), whitelist: WhitelistRepo = Depends(get_whitelist), ) -> None: # URL-decode in case the path contains special chars (did:key contains ':'). @@ -205,7 +201,7 @@ async def whitelist_remove( @admin_router.get("/admin/keys", response_model=OwnKeyDTO) -async def keys_view(_: None = Depends(require_api_key)) -> OwnKeyDTO: +async def keys_view() -> OwnKeyDTO: from .config import cfg key_store = SigningKeyStore(cfg.signing_key_path) @@ -213,4 +209,3 @@ async def keys_view(_: None = Depends(require_api_key)) -> OwnKeyDTO: return OwnKeyDTO( issuer_did_key=key_store.issuer_did_key(), key_path=cfg.signing_key_path ) - diff --git a/dva-vc-manager/tests/test_routes.py b/dva-vc-manager/tests/test_routes.py index d034ee31..d4d06cf9 100644 --- a/dva-vc-manager/tests/test_routes.py +++ b/dva-vc-manager/tests/test_routes.py @@ -147,14 +147,3 @@ def test_aov_verify_rejects_malformed_jws_with_400( asyncio.run(whitelist.add(_KNOWN_DID_KEY)) r = client.post("/aov/verify", json={"jws": "not.a.jws.at.all"}) assert r.status_code == 400 - - -def test_admin_whitelist_unauthorised_when_no_api_key(client: TestClient) -> None: - r = client.get("/admin/whitelist") - assert r.status_code == 401 - r = client.post("/admin/whitelist", json={"did_key": _KNOWN_DID_KEY}) - assert r.status_code == 401 - r = client.delete(f"/admin/whitelist/{_KNOWN_DID_KEY}") - assert r.status_code == 401 - r = client.get("/admin/keys") - assert r.status_code == 401 \ No newline at end of file From 80ed34185076629575c08652c0885ce6382c8ae4 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Fri, 31 Jul 2026 16:03:01 +0200 Subject: [PATCH 06/22] refactor(vc): improve OpenAPI spec serving --- dva-vc-manager/Dockerfile | 1 + dva-vc-manager/pyproject.toml | 1 + dva-vc-manager/src/dva_vc_manager/config.py | 4 + dva-vc-manager/src/dva_vc_manager/main.py | 96 +++++++++------------ dva-vc-manager/uv.lock | 4 +- 5 files changed, 52 insertions(+), 54 deletions(-) diff --git a/dva-vc-manager/Dockerfile b/dva-vc-manager/Dockerfile index 64d2df0f..fe1f8750 100644 --- a/dva-vc-manager/Dockerfile +++ b/dva-vc-manager/Dockerfile @@ -16,6 +16,7 @@ RUN \ "pydantic>=2.10.6" \ "pydantic-settings>=2.5.0" \ "pynacl>=1.5.0" \ + "pyyaml>=6.0" \ "structlog>=25.1.0" \ "uvicorn>=0.34.3" diff --git a/dva-vc-manager/pyproject.toml b/dva-vc-manager/pyproject.toml index ca84d2fe..96dffebb 100644 --- a/dva-vc-manager/pyproject.toml +++ b/dva-vc-manager/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "pydantic>=2.10.6", "pydantic-settings>=2.5.0", "pynacl>=1.5.0", + "pyyaml>=6.0", "structlog>=25.1.0", "uvicorn>=0.34.3", ] diff --git a/dva-vc-manager/src/dva_vc_manager/config.py b/dva-vc-manager/src/dva_vc_manager/config.py index 4147358f..5d501269 100644 --- a/dva-vc-manager/src/dva_vc_manager/config.py +++ b/dva-vc-manager/src/dva_vc_manager/config.py @@ -32,6 +32,10 @@ class Config: # whitelist repo; empty → fall back to in-memory FakeWhitelist. postgres_dsn: str = os.getenv("DVA_VC_MANAGER_DB_URL", "") + # Hand-written OpenAPI spec served at /swagger. + # Missing → fall back to FastAPI's auto-generated schema. + openapi_file: str = os.getenv("DVA_VC_MANAGER_OPENAPI_FILE", "/app/openapi.yaml") + cfg = Config() diff --git a/dva-vc-manager/src/dva_vc_manager/main.py b/dva-vc-manager/src/dva_vc_manager/main.py index eb2cda57..6695d485 100644 --- a/dva-vc-manager/src/dva_vc_manager/main.py +++ b/dva-vc-manager/src/dva_vc_manager/main.py @@ -3,40 +3,49 @@ from __future__ import annotations import logging -import os + +from typing import Any, Callable + +import yaml from fastapi import FastAPI -from fastapi.responses import HTMLResponse, PlainTextResponse +from fastapi.openapi.utils import get_openapi from .config import cfg, setup_logging from .routes import admin_router, router -_SWAGGER_UI_HTML = """\ - - - - DVA VC Manager — Swagger UI - - - - -
- - - - - -""" +logger = logging.getLogger(__name__) + + +def _spec_loader(app: FastAPI) -> Callable[[], dict[str, Any]]: + """ + Build the ``app.openapi`` callable serving the hand-written spec. + + FastAPI caches the result in ``app.openapi_schema`` and renders both the + Swagger UI and ReDoc pages from it, so the spec is read from disk once. + """ + + def openapi() -> dict[str, Any]: + if app.openapi_schema: + return app.openapi_schema + try: + with open(cfg.openapi_file, "r", encoding="utf-8") as fh: + app.openapi_schema = yaml.safe_load(fh) + except FileNotFoundError: + logger.warning( + "OpenAPI spec %s not found – falling back to the auto-generated " + "schema. Set DVA_VC_MANAGER_OPENAPI_FILE to the hand-written spec.", + cfg.openapi_file, + ) + app.openapi_schema = get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + ) + return app.openapi_schema + + return openapi def _build_production_whitelist(): @@ -85,33 +94,15 @@ def create_app() -> FastAPI: "issuance in the synchronous attestation flow." ), version="0.1.0", - # Disable auto-generated docs – hand-written spec is served at /swagger - docs_url=None, - redoc_url=None, - openapi_url=None, + # Docs pages are FastAPI's own; the schema behind them is the + # hand-written spec installed as app.openapi below. + docs_url="/swagger", + redoc_url="/redoc", + openapi_url="/swagger/openapi.json", ) app.include_router(router) app.include_router(admin_router) - - @app.get("/swagger", response_class=HTMLResponse, include_in_schema=False) - async def swagger_ui() -> HTMLResponse: - """Serve the Swagger UI loaded from the hand-written OpenAPI spec.""" - return HTMLResponse(content=_SWAGGER_UI_HTML) - - @app.get( - "/swagger/openapi.yaml", - response_class=PlainTextResponse, - include_in_schema=False, - ) - async def swagger_spec() -> PlainTextResponse: - """Serve the hand-written OpenAPI spec YAML from disk.""" - spec_path = os.environ.get("DVA_VC_MANAGER_OPENAPI_FILE", "/app/openapi.yaml") - try: - with open(spec_path, "r", encoding="utf-8") as fh: - content = fh.read() - except FileNotFoundError: - return PlainTextResponse(content="# spec file not found", status_code=404) - return PlainTextResponse(content=content, media_type="application/yaml") + app.openapi = _spec_loader(app) return app @@ -135,4 +126,3 @@ def cli() -> None: port=cfg.port, log_level=_level_to_str(cfg.log_level), ) - diff --git a/dva-vc-manager/uv.lock b/dva-vc-manager/uv.lock index d931fb51..1711c7b6 100644 --- a/dva-vc-manager/uv.lock +++ b/dva-vc-manager/uv.lock @@ -271,6 +271,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pynacl" }, + { name = "pyyaml" }, { name = "structlog" }, { name = "uvicorn" }, ] @@ -291,6 +292,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.10.6" }, { name = "pydantic-settings", specifier = ">=2.5.0" }, { name = "pynacl", specifier = ">=1.5.0" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "structlog", specifier = ">=25.1.0" }, { name = "uvicorn", specifier = ">=0.34.3" }, ] @@ -308,7 +310,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { 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 = [ From 8a3050ff2e17fdf0fdb3d0469abcf095bb04c131 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Fri, 31 Jul 2026 16:08:24 +0200 Subject: [PATCH 07/22] refactor(vc): remove dead code --- dva-vc-manager/src/dva_vc_manager/config.py | 4 ---- .../src/dva_vc_manager/dependencies.py | 10 ++------ dva-vc-manager/src/dva_vc_manager/main.py | 23 ++----------------- dva-vc-manager/src/dva_vc_manager/signing.py | 14 ----------- dva-vc-manager/tests/test_routes.py | 2 -- 5 files changed, 4 insertions(+), 49 deletions(-) diff --git a/dva-vc-manager/src/dva_vc_manager/config.py b/dva-vc-manager/src/dva_vc_manager/config.py index 5d501269..f5e50c6a 100644 --- a/dva-vc-manager/src/dva_vc_manager/config.py +++ b/dva-vc-manager/src/dva_vc_manager/config.py @@ -8,10 +8,6 @@ from sys import stderr -def _truthy(value: str | None) -> bool: - return value is not None and value.lower() in {"1", "true", "yes", "on"} - - def _log_level(value: str | None) -> int: return getattr(logging, (value or "INFO").upper(), logging.INFO) diff --git a/dva-vc-manager/src/dva_vc_manager/dependencies.py b/dva-vc-manager/src/dva_vc_manager/dependencies.py index 9c0c6a48..f15a88ea 100644 --- a/dva-vc-manager/src/dva_vc_manager/dependencies.py +++ b/dva-vc-manager/src/dva_vc_manager/dependencies.py @@ -16,18 +16,12 @@ async def get_whitelist() -> WhitelistRepo: global _whitelist_singleton if _whitelist_singleton is None: if cfg.postgres_dsn: - from .main import _build_production_whitelist_async + from .main import _build_production_whitelist - _whitelist_singleton = await _build_production_whitelist_async() + _whitelist_singleton = await _build_production_whitelist() else: logger.warning( "DVA_VC_MANAGER_DB_URL is not set – falling back to in-memory FakeWhitelist" ) _whitelist_singleton = FakeWhitelist() return _whitelist_singleton - - -def install_whitelist_for_tests(repo: WhitelistRepo) -> None: - global _whitelist_singleton - _whitelist_singleton = repo - diff --git a/dva-vc-manager/src/dva_vc_manager/main.py b/dva-vc-manager/src/dva_vc_manager/main.py index 6695d485..58fb02e8 100644 --- a/dva-vc-manager/src/dva_vc_manager/main.py +++ b/dva-vc-manager/src/dva_vc_manager/main.py @@ -3,11 +3,9 @@ from __future__ import annotations import logging - from typing import Any, Callable import yaml - from fastapi import FastAPI from fastapi.openapi.utils import get_openapi @@ -48,25 +46,8 @@ def openapi() -> dict[str, Any]: return openapi -def _build_production_whitelist(): - """ - Construct the async-backed whitelist repository. - - DEPRECATED sync stub – kept only for test-override compatibility. - Production callers must use the async version below. - """ - raise RuntimeError( - "Call _build_production_whitelist_async() from within an async context." - ) - - -async def _build_production_whitelist_async(): - """ - Construct the async-backed whitelist repository. - - Uses await (not asyncio.run) so it is safe to call from within the - uvicorn event loop. - """ +async def _build_production_whitelist(): + """Construct the async-backed whitelist repository.""" import asyncpg from .whitelist import PgWhitelist diff --git a/dva-vc-manager/src/dva_vc_manager/signing.py b/dva-vc-manager/src/dva_vc_manager/signing.py index 4b63aeb9..ac812f92 100644 --- a/dva-vc-manager/src/dva_vc_manager/signing.py +++ b/dva-vc-manager/src/dva_vc_manager/signing.py @@ -14,8 +14,6 @@ from nacl.exceptions import BadSignatureError from nacl.signing import SigningKey, VerifyKey -from .did_key import did_key_to_public_key - # JWS header constants JWS_HEADER_ALG = "EdDSA" JWS_HEADER_TYPE = "VC+LD-JSON+JWS" @@ -91,14 +89,6 @@ def verify_jws(jws: str, public_key: VerifyKey) -> bool: return False -def verify_jws_with_did_key(jws: str, did_key: str) -> bool: - """Convenience: derive the Ed25519 public key from a did:key and verify.""" - public_key = did_key_to_public_key(did_key) - # VerifyKey accepts the raw 32-byte encoding – same bytes that did_key - # just decoded for us. - return verify_jws(jws, VerifyKey(bytes(public_key))) - - def decode_payload(jws: str) -> dict[str, Any]: """Decode (without verifying) the payload middle segment of a JWS.""" parts = jws.split(".") @@ -127,7 +117,3 @@ class AovClaims(BaseModel): payload: str model_config = {"populate_by_name": True} - - @property - def vcId(self) -> str: # noqa: N802 — parity with Kotlin property name. - return self.vc_id diff --git a/dva-vc-manager/tests/test_routes.py b/dva-vc-manager/tests/test_routes.py index d4d06cf9..3e01e3b5 100644 --- a/dva-vc-manager/tests/test_routes.py +++ b/dva-vc-manager/tests/test_routes.py @@ -7,7 +7,6 @@ * ``POST /aov/verify`` fails-closed when whitelist is empty. * ``POST /aov/verify`` rejects when issuer not whitelisted. * ``POST /aov/verify`` rejects a clearly malformed JWS with 400. -* Admin endpoints fail-closed 401 when no API key is configured. """ from __future__ import annotations @@ -41,7 +40,6 @@ def client( from dva_vc_manager import config as cfg_module cfg_module.cfg.signing_key_path = str(tmp_path / "key.pem") - cfg_module.cfg.api_key = "" cfg_module.cfg.postgres_dsn = "" app = create_app() From dc2505a579d4cf217fba9b15bb34a56a60a7ea37 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Fri, 31 Jul 2026 17:19:01 +0200 Subject: [PATCH 08/22] refactor(vc): hoist deferred imports to module level --- dva-vc-manager/src/dva_vc_manager/config.py | 12 +++--- .../src/dva_vc_manager/dependencies.py | 3 ++ dva-vc-manager/src/dva_vc_manager/keys.py | 4 +- dva-vc-manager/src/dva_vc_manager/main.py | 9 ++-- dva-vc-manager/src/dva_vc_manager/routes.py | 8 +--- dva-vc-manager/src/dva_vc_manager/signing.py | 42 ++++++++----------- .../src/dva_vc_manager/whitelist.py | 5 +-- dva-vc-manager/tests/test_jws.py | 4 +- dva-vc-manager/tests/test_routes.py | 7 ++-- 9 files changed, 40 insertions(+), 54 deletions(-) diff --git a/dva-vc-manager/src/dva_vc_manager/config.py b/dva-vc-manager/src/dva_vc_manager/config.py index f5e50c6a..4245bb87 100644 --- a/dva-vc-manager/src/dva_vc_manager/config.py +++ b/dva-vc-manager/src/dva_vc_manager/config.py @@ -7,6 +7,12 @@ from dataclasses import dataclass from sys import stderr +import structlog +from structlog import make_filtering_bound_logger +from structlog.dev import ConsoleRenderer +from structlog.processors import JSONRenderer, StackInfoRenderer, TimeStamper +from structlog.stdlib import add_log_level + def _log_level(value: str | None) -> int: return getattr(logging, (value or "INFO").upper(), logging.INFO) @@ -37,12 +43,6 @@ class Config: def setup_logging() -> None: - import structlog - from structlog import make_filtering_bound_logger - from structlog.dev import ConsoleRenderer - from structlog.processors import JSONRenderer, StackInfoRenderer, TimeStamper - from structlog.stdlib import add_log_level - shared = [add_log_level, StackInfoRenderer(), TimeStamper(fmt="iso")] processors = shared + ([ConsoleRenderer()] if stderr.isatty() else [JSONRenderer()]) structlog.configure( diff --git a/dva-vc-manager/src/dva_vc_manager/dependencies.py b/dva-vc-manager/src/dva_vc_manager/dependencies.py index f15a88ea..5c3a6a1c 100644 --- a/dva-vc-manager/src/dva_vc_manager/dependencies.py +++ b/dva-vc-manager/src/dva_vc_manager/dependencies.py @@ -16,6 +16,9 @@ async def get_whitelist() -> WhitelistRepo: global _whitelist_singleton if _whitelist_singleton is None: if cfg.postgres_dsn: + # Deferred: main imports routes, which imports this module, so a + # top-level import here would be circular. The real fix is to + # move the factory out of main -- see the DB lifecycle cleanup. from .main import _build_production_whitelist _whitelist_singleton = await _build_production_whitelist() diff --git a/dva-vc-manager/src/dva_vc_manager/keys.py b/dva-vc-manager/src/dva_vc_manager/keys.py index a91758d7..2c388aa5 100644 --- a/dva-vc-manager/src/dva_vc_manager/keys.py +++ b/dva-vc-manager/src/dva_vc_manager/keys.py @@ -19,6 +19,8 @@ from nacl.public import PublicKey from nacl.signing import SigningKey, VerifyKey +from .did_key import public_key_to_did_key + __all__ = ["SigningKeyStore", "KeyPair"] @@ -97,8 +99,6 @@ def load_or_generate(self) -> KeyPair: def issuer_did_key(self) -> str: """Return the ``did:key`` identifier of the loaded public key.""" - from .did_key import public_key_to_did_key - if self._cached is None: raise RuntimeError( "SigningKeyStore.load_or_generate() must be called before issuer_did_key()" diff --git a/dva-vc-manager/src/dva_vc_manager/main.py b/dva-vc-manager/src/dva_vc_manager/main.py index 58fb02e8..4bc8a716 100644 --- a/dva-vc-manager/src/dva_vc_manager/main.py +++ b/dva-vc-manager/src/dva_vc_manager/main.py @@ -5,12 +5,15 @@ import logging from typing import Any, Callable +import asyncpg +import uvicorn import yaml from fastapi import FastAPI from fastapi.openapi.utils import get_openapi from .config import cfg, setup_logging from .routes import admin_router, router +from .whitelist import PgWhitelist logger = logging.getLogger(__name__) @@ -48,10 +51,6 @@ def openapi() -> dict[str, Any]: async def _build_production_whitelist(): """Construct the async-backed whitelist repository.""" - import asyncpg - - from .whitelist import PgWhitelist - if not cfg.postgres_dsn: raise RuntimeError( "DVA_VC_MANAGER_DB_URL is not set — cannot boot PgWhitelist. " @@ -99,8 +98,6 @@ def _level_to_str(level: int) -> str: def cli() -> None: - import uvicorn - uvicorn.run( "dva_vc_manager.main:app", host=cfg.host, diff --git a/dva-vc-manager/src/dva_vc_manager/routes.py b/dva-vc-manager/src/dva_vc_manager/routes.py index bff680f8..117a16f2 100644 --- a/dva-vc-manager/src/dva_vc_manager/routes.py +++ b/dva-vc-manager/src/dva_vc_manager/routes.py @@ -29,7 +29,9 @@ from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, status +from nacl.signing import VerifyKey +from .config import cfg from .dependencies import get_whitelist from .did_key import did_key_to_public_key from .keys import SigningKeyStore @@ -56,8 +58,6 @@ def _get_key_store() -> SigningKeyStore: ``app.dependency_overrides`` so the test suite can swap in a key store at a temp-file path. """ - from .config import cfg - return SigningKeyStore(cfg.signing_key_path) @@ -143,8 +143,6 @@ async def aov_verify( # 6. Verify the Ed25519 signature. A structurally-valid JWS whose # signature does not verify returns verified=false. - from nacl.signing import VerifyKey - try: ok = verify_jws(req.jws, VerifyKey(bytes(public_key))) except Exception as e: @@ -202,8 +200,6 @@ async def whitelist_remove( @admin_router.get("/admin/keys", response_model=OwnKeyDTO) async def keys_view() -> OwnKeyDTO: - from .config import cfg - key_store = SigningKeyStore(cfg.signing_key_path) key_store.load_or_generate() return OwnKeyDTO( diff --git a/dva-vc-manager/src/dva_vc_manager/signing.py b/dva-vc-manager/src/dva_vc_manager/signing.py index ac812f92..bcb15cf8 100644 --- a/dva-vc-manager/src/dva_vc_manager/signing.py +++ b/dva-vc-manager/src/dva_vc_manager/signing.py @@ -13,6 +13,7 @@ from nacl.exceptions import BadSignatureError from nacl.signing import SigningKey, VerifyKey +from pydantic import BaseModel # JWS header constants JWS_HEADER_ALG = "EdDSA" @@ -22,6 +23,21 @@ AOV_TYPE = "AttestationOfVeracity" +class AovClaims(BaseModel): + """The eight AoV credentialSubject claims.""" + + vc_id: str + valid_since: str + subject: str + issuer_id: str + record_id: str + contract_id: str + data_exchange_id: str + payload: str + + model_config = {"populate_by_name": True} + + def _b64url(data: bytes) -> str: """Standard JWS base64url **without** padding (per RFC 7515 §2.2.2).""" return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") @@ -38,7 +54,7 @@ def _json_compact(obj: dict[str, Any]) -> bytes: return json.dumps(obj, separators=(",", ":"), ensure_ascii=False).encode("utf-8") -def build_aov_payload(claims: "AovClaims", issuer_did_key: str) -> dict[str, Any]: +def build_aov_payload(claims: AovClaims, issuer_did_key: str) -> dict[str, Any]: """Build the W3C VC 2.0 JSON-LD payload.""" return { "@context": [VC_CONTEXT], @@ -62,7 +78,7 @@ def _jws_header() -> dict[str, str]: return {"alg": JWS_HEADER_ALG, "typ": JWS_HEADER_TYPE} -def sign_jws(claims: "AovClaims", signing_key: SigningKey, issuer_did_key: str) -> str: +def sign_jws(claims: AovClaims, signing_key: SigningKey, issuer_did_key: str) -> str: """Sign and produce a compact JWS string.""" header_b64 = _b64url(_json_compact(_jws_header())) payload_b64 = _b64url(_json_compact(build_aov_payload(claims, issuer_did_key))) @@ -95,25 +111,3 @@ def decode_payload(jws: str) -> dict[str, Any]: if len(parts) != 3: raise ValueError("Compact JWS must have 3 dot-separated parts") return json.loads(_b64url_decode(parts[1])) - - -# AoV claims model – defined at the bottom of the module so older -# pydantic-style annotations above ("AovClaims") resolve via forward -# reference. Importing this class is the canonical way callers construct -# the claims payload. -from pydantic import BaseModel # noqa: E402 - - -class AovClaims(BaseModel): - """The eight AoV credentialSubject claims.""" - - vc_id: str - valid_since: str - subject: str - issuer_id: str - record_id: str - contract_id: str - data_exchange_id: str - payload: str - - model_config = {"populate_by_name": True} diff --git a/dva-vc-manager/src/dva_vc_manager/whitelist.py b/dva-vc-manager/src/dva_vc_manager/whitelist.py index a557458d..f7ff9939 100644 --- a/dva-vc-manager/src/dva_vc_manager/whitelist.py +++ b/dva-vc-manager/src/dva_vc_manager/whitelist.py @@ -12,6 +12,7 @@ from typing import Any, Optional, Protocol from uuid import UUID, uuid4 +from asyncpg.exceptions import UniqueViolationError from pydantic import BaseModel __all__ = ["WhitelistEntry", "WhitelistRepo", "FakeWhitelist", "PgWhitelist"] @@ -85,8 +86,6 @@ async def all(self) -> list[WhitelistEntry]: return [WhitelistEntry.from_row(r) for r in rows] async def add(self, did_key: str, label: Optional[str] = None) -> WhitelistEntry: - import asyncpg.exceptions - entry_id = uuid4() async with self._pool.acquire() as conn: try: @@ -96,7 +95,7 @@ async def add(self, did_key: str, label: Optional[str] = None) -> WhitelistEntry did_key, label, ) - except asyncpg.exceptions.UniqueViolationError: + except UniqueViolationError: existing = await conn.fetchrow( "SELECT id, did_key, label FROM did_key_whitelist WHERE did_key = $1", did_key, diff --git a/dva-vc-manager/tests/test_jws.py b/dva-vc-manager/tests/test_jws.py index f2cc2935..f5a2137c 100644 --- a/dva-vc-manager/tests/test_jws.py +++ b/dva-vc-manager/tests/test_jws.py @@ -5,7 +5,7 @@ import pytest from nacl.signing import SigningKey, VerifyKey -from dva_vc_manager.signing import AovClaims, sign_jws, verify_jws +from dva_vc_manager.signing import AovClaims, decode_payload, sign_jws, verify_jws def _sample_claims() -> AovClaims: @@ -64,8 +64,6 @@ def test_rejection_of_a_clearly_malformed_jws() -> None: def test_payload_shape_includes_context_type_issuer_validfrom_subject() -> None: """Validate the W3C VC JSON-LD structure — byte-identical to the Kotlin ``buildAovPayload`` at ``JwsSigner.kt:39-57``.""" - from dva_vc_manager.signing import decode_payload - signing_key = SigningKey.generate() jws = sign_jws(_sample_claims(), signing_key, _KNOWN_DID_KEY) payload = decode_payload(jws) diff --git a/dva-vc-manager/tests/test_routes.py b/dva-vc-manager/tests/test_routes.py index 3e01e3b5..8d577f28 100644 --- a/dva-vc-manager/tests/test_routes.py +++ b/dva-vc-manager/tests/test_routes.py @@ -11,9 +11,12 @@ from __future__ import annotations +import asyncio + import pytest from fastapi.testclient import TestClient +from dva_vc_manager import config as cfg_module from dva_vc_manager.dependencies import get_whitelist from dva_vc_manager.main import create_app from dva_vc_manager.signing import decode_payload @@ -37,8 +40,6 @@ def client( whitelist: FakeWhitelist, monkeypatch: pytest.MonkeyPatch, tmp_path ) -> TestClient: monkeypatch.setenv("DVA_VC_MANAGER_SIGNING_KEY_PATH", str(tmp_path / "key.pem")) - from dva_vc_manager import config as cfg_module - cfg_module.cfg.signing_key_path = str(tmp_path / "key.pem") cfg_module.cfg.postgres_dsn = "" @@ -140,8 +141,6 @@ async def test_aov_verify_rejects_when_issuer_not_whitelisted( def test_aov_verify_rejects_malformed_jws_with_400( client: TestClient, whitelist: FakeWhitelist ) -> None: - import asyncio - asyncio.run(whitelist.add(_KNOWN_DID_KEY)) r = client.post("/aov/verify", json={"jws": "not.a.jws.at.all"}) assert r.status_code == 400 From e4f9c78485f2616d8539fca5ad6cb2548630032a Mon Sep 17 00:00:00 2001 From: bzp99 Date: Fri, 31 Jul 2026 17:37:55 +0200 Subject: [PATCH 09/22] feat(vc): use structlog for logging --- dva-vc-manager/src/dva_vc_manager/config.py | 27 +++------------ .../src/dva_vc_manager/dependencies.py | 8 ++--- dva-vc-manager/src/dva_vc_manager/log.py | 34 +++++++++++++++++++ dva-vc-manager/src/dva_vc_manager/main.py | 21 ++++-------- 4 files changed, 49 insertions(+), 41 deletions(-) create mode 100644 dva-vc-manager/src/dva_vc_manager/log.py diff --git a/dva-vc-manager/src/dva_vc_manager/config.py b/dva-vc-manager/src/dva_vc_manager/config.py index 4245bb87..07c10c32 100644 --- a/dva-vc-manager/src/dva_vc_manager/config.py +++ b/dva-vc-manager/src/dva_vc_manager/config.py @@ -2,27 +2,18 @@ from __future__ import annotations -import logging import os from dataclasses import dataclass -from sys import stderr - -import structlog -from structlog import make_filtering_bound_logger -from structlog.dev import ConsoleRenderer -from structlog.processors import JSONRenderer, StackInfoRenderer, TimeStamper -from structlog.stdlib import add_log_level - - -def _log_level(value: str | None) -> int: - return getattr(logging, (value or "INFO").upper(), logging.INFO) @dataclass class Config: host: str = os.getenv("DVA_VC_MANAGER_HOST", "0.0.0.0") port: int = int(os.getenv("DVA_VC_MANAGER_PORT", "8000")) - log_level: int = _log_level(os.getenv("DVA_VC_MANAGER_LOG_LEVEL", "INFO")) + + # Level name, lowercased: structlog and uvicorn both take one of + # "critical", "error", "warning", "info", "debug". + log_level: str = os.getenv("DVA_VC_MANAGER_LOG_LEVEL", "info").lower() # Ed25519 signing key file path. # Loaded on first use; created and persisted (0600) if missing. @@ -40,13 +31,3 @@ class Config: cfg = Config() - - -def setup_logging() -> None: - shared = [add_log_level, StackInfoRenderer(), TimeStamper(fmt="iso")] - processors = shared + ([ConsoleRenderer()] if stderr.isatty() else [JSONRenderer()]) - structlog.configure( - processors=processors, - context_class=dict, - wrapper_class=make_filtering_bound_logger(cfg.log_level), - ) diff --git a/dva-vc-manager/src/dva_vc_manager/dependencies.py b/dva-vc-manager/src/dva_vc_manager/dependencies.py index 5c3a6a1c..51598218 100644 --- a/dva-vc-manager/src/dva_vc_manager/dependencies.py +++ b/dva-vc-manager/src/dva_vc_manager/dependencies.py @@ -2,12 +2,11 @@ from __future__ import annotations -import logging - from .config import cfg +from .log import get_logger from .whitelist import FakeWhitelist, WhitelistRepo -logger = logging.getLogger(__name__) +logger = get_logger() _whitelist_singleton: WhitelistRepo | None = None @@ -24,7 +23,8 @@ async def get_whitelist() -> WhitelistRepo: _whitelist_singleton = await _build_production_whitelist() else: logger.warning( - "DVA_VC_MANAGER_DB_URL is not set – falling back to in-memory FakeWhitelist" + "DVA_VC_MANAGER_DB_URL is not set, falling back to in-memory " + "FakeWhitelist; the whitelist will not survive a restart" ) _whitelist_singleton = FakeWhitelist() return _whitelist_singleton diff --git a/dva-vc-manager/src/dva_vc_manager/log.py b/dva-vc-manager/src/dva_vc_manager/log.py new file mode 100644 index 00000000..275cfefc --- /dev/null +++ b/dva-vc-manager/src/dva_vc_manager/log.py @@ -0,0 +1,34 @@ +""" +Logging setup for the DVA VC Manager. + +structlog renders human-readable output when stderr is a TTY and JSON otherwise. +""" + +from __future__ import annotations + +from sys import stderr + +import structlog +from structlog import make_filtering_bound_logger +from structlog.dev import ConsoleRenderer +from structlog.processors import JSONRenderer, StackInfoRenderer, TimeStamper +from structlog.stdlib import add_log_level +from structlog.typing import FilteringBoundLogger + +from .config import cfg + + +def setup_logging() -> None: + """Configure structlog. Called once from the app factory.""" + shared = [add_log_level, StackInfoRenderer(), TimeStamper(fmt="iso")] + processors = shared + ([ConsoleRenderer()] if stderr.isatty() else [JSONRenderer()]) + structlog.configure( + processors=processors, + context_class=dict, + wrapper_class=make_filtering_bound_logger(cfg.log_level), + ) + + +def get_logger() -> FilteringBoundLogger: + """Return the application logger.""" + return structlog.get_logger() diff --git a/dva-vc-manager/src/dva_vc_manager/main.py b/dva-vc-manager/src/dva_vc_manager/main.py index 4bc8a716..3b1e0b72 100644 --- a/dva-vc-manager/src/dva_vc_manager/main.py +++ b/dva-vc-manager/src/dva_vc_manager/main.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from typing import Any, Callable import asyncpg @@ -11,11 +10,12 @@ from fastapi import FastAPI from fastapi.openapi.utils import get_openapi -from .config import cfg, setup_logging +from .config import cfg +from .log import get_logger, setup_logging from .routes import admin_router, router from .whitelist import PgWhitelist -logger = logging.getLogger(__name__) +logger = get_logger() def _spec_loader(app: FastAPI) -> Callable[[], dict[str, Any]]: @@ -34,9 +34,9 @@ def openapi() -> dict[str, Any]: app.openapi_schema = yaml.safe_load(fh) except FileNotFoundError: logger.warning( - "OpenAPI spec %s not found – falling back to the auto-generated " - "schema. Set DVA_VC_MANAGER_OPENAPI_FILE to the hand-written spec.", - cfg.openapi_file, + "OpenAPI spec not found, falling back to the auto-generated schema; " + "set DVA_VC_MANAGER_OPENAPI_FILE to the hand-written spec", + openapi_file=cfg.openapi_file, ) app.openapi_schema = get_openapi( title=app.title, @@ -90,17 +90,10 @@ def create_app() -> FastAPI: app = create_app() -def _level_to_str(level: int) -> str: - for name, val in logging._levelToName.items(): - if val == level: - return name.lower() - return "info" - - def cli() -> None: uvicorn.run( "dva_vc_manager.main:app", host=cfg.host, port=cfg.port, - log_level=_level_to_str(cfg.log_level), + log_level=cfg.log_level, ) From 26596854aca1f909dbc2e1507d56ef57201a385d Mon Sep 17 00:00:00 2001 From: bzp99 Date: Fri, 31 Jul 2026 18:25:15 +0200 Subject: [PATCH 10/22] refactor(vc): build whitelist repo during app startup --- .../src/dva_vc_manager/dependencies.py | 49 +++++++++++-------- dva-vc-manager/src/dva_vc_manager/main.py | 27 +++++----- .../src/dva_vc_manager/whitelist.py | 23 +++++++-- dva-vc-manager/tests/test_routes.py | 16 ++++++ 4 files changed, 74 insertions(+), 41 deletions(-) diff --git a/dva-vc-manager/src/dva_vc_manager/dependencies.py b/dva-vc-manager/src/dva_vc_manager/dependencies.py index 51598218..47b1660b 100644 --- a/dva-vc-manager/src/dva_vc_manager/dependencies.py +++ b/dva-vc-manager/src/dva_vc_manager/dependencies.py @@ -2,29 +2,36 @@ from __future__ import annotations +import asyncpg +from fastapi import Request + from .config import cfg from .log import get_logger -from .whitelist import FakeWhitelist, WhitelistRepo +from .whitelist import FakeWhitelist, PgWhitelist, WhitelistRepo logger = get_logger() -_whitelist_singleton: WhitelistRepo | None = None - - -async def get_whitelist() -> WhitelistRepo: - global _whitelist_singleton - if _whitelist_singleton is None: - if cfg.postgres_dsn: - # Deferred: main imports routes, which imports this module, so a - # top-level import here would be circular. The real fix is to - # move the factory out of main -- see the DB lifecycle cleanup. - from .main import _build_production_whitelist - - _whitelist_singleton = await _build_production_whitelist() - else: - logger.warning( - "DVA_VC_MANAGER_DB_URL is not set, falling back to in-memory " - "FakeWhitelist; the whitelist will not survive a restart" - ) - _whitelist_singleton = FakeWhitelist() - return _whitelist_singleton + +async def build_whitelist() -> WhitelistRepo: + """ + Construct the whitelist repo from config. Called once, at startup. + + With no DSN configured this falls back to the in-memory repo so the + service still boots for local development. + """ + if not cfg.postgres_dsn: + logger.warning( + "DVA_VC_MANAGER_DB_URL is not set, falling back to in-memory " + "FakeWhitelist; the whitelist will not survive a restart" + ) + return FakeWhitelist() + + pool = await asyncpg.create_pool(dsn=cfg.postgres_dsn, min_size=1, max_size=4) + repo = PgWhitelist(pool) + await repo.ensure_schema() + return repo + + +def get_whitelist(request: Request) -> WhitelistRepo: + """Return the whitelist repo built during startup.""" + return request.app.state.whitelist diff --git a/dva-vc-manager/src/dva_vc_manager/main.py b/dva-vc-manager/src/dva_vc_manager/main.py index 3b1e0b72..19744657 100644 --- a/dva-vc-manager/src/dva_vc_manager/main.py +++ b/dva-vc-manager/src/dva_vc_manager/main.py @@ -2,18 +2,18 @@ from __future__ import annotations -from typing import Any, Callable +from contextlib import asynccontextmanager +from typing import Any, AsyncIterator, Callable -import asyncpg import uvicorn import yaml from fastapi import FastAPI from fastapi.openapi.utils import get_openapi from .config import cfg +from .dependencies import build_whitelist from .log import get_logger, setup_logging from .routes import admin_router, router -from .whitelist import PgWhitelist logger = get_logger() @@ -49,23 +49,20 @@ def openapi() -> dict[str, Any]: return openapi -async def _build_production_whitelist(): - """Construct the async-backed whitelist repository.""" - if not cfg.postgres_dsn: - raise RuntimeError( - "DVA_VC_MANAGER_DB_URL is not set — cannot boot PgWhitelist. " - "Either set it or override get_whitelist dependency for tests." - ) - - pool = await asyncpg.create_pool(dsn=cfg.postgres_dsn, min_size=1, max_size=4) - repo = PgWhitelist(pool) - await repo._ensure_schema() - return repo +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """Build the whitelist repo (and its connection pool) up front.""" + app.state.whitelist = await build_whitelist() + try: + yield + finally: + await app.state.whitelist.close() def create_app() -> FastAPI: setup_logging() app = FastAPI( + lifespan=lifespan, title="DVA VC Manager", description=( "Issues and verifies Attestation of Veracity (AoV) credentials as " diff --git a/dva-vc-manager/src/dva_vc_manager/whitelist.py b/dva-vc-manager/src/dva_vc_manager/whitelist.py index f7ff9939..00b3c0e8 100644 --- a/dva-vc-manager/src/dva_vc_manager/whitelist.py +++ b/dva-vc-manager/src/dva_vc_manager/whitelist.py @@ -12,6 +12,7 @@ from typing import Any, Optional, Protocol from uuid import UUID, uuid4 +import asyncpg from asyncpg.exceptions import UniqueViolationError from pydantic import BaseModel @@ -34,6 +35,7 @@ async def add(self, did_key: str, label: Optional[str]) -> WhitelistEntry: ... async def remove(self, did_key: str) -> bool: ... async def find(self, did_key: str) -> Optional[WhitelistEntry]: ... async def contains(self, did_key: str) -> bool: ... + async def close(self) -> None: ... class FakeWhitelist: @@ -61,14 +63,21 @@ async def find(self, did_key: str) -> Optional[WhitelistEntry]: async def contains(self, did_key: str) -> bool: return did_key in self._entries + async def close(self) -> None: + """No-op; nothing to release.""" + class PgWhitelist: - """PostgreSQL whitelist (asyncpg).""" + """ + PostgreSQL whitelist (asyncpg). + + Owns the connection pool. + """ - def __init__(self, pool): # type: ignore[no-untyped-def] + def __init__(self, pool: asyncpg.Pool) -> None: self._pool = pool - async def _ensure_schema(self) -> None: + async def ensure_schema(self) -> None: async with self._pool.acquire() as conn: await conn.execute( """ @@ -105,10 +114,11 @@ async def add(self, did_key: str, label: Optional[str] = None) -> WhitelistEntry async def remove(self, did_key: str) -> bool: async with self._pool.acquire() as conn: - result = await conn.execute( + status = await conn.execute( "DELETE FROM did_key_whitelist WHERE did_key = $1", did_key ) - return result.endswith("1") # "DELETE 1" → true + # asyncpg returns the command tag, e.g. "DELETE 1". + return int(status.rpartition(" ")[2]) > 0 async def find(self, did_key: str) -> Optional[WhitelistEntry]: async with self._pool.acquire() as conn: @@ -124,3 +134,6 @@ async def contains(self, did_key: str) -> bool: "SELECT 1 FROM did_key_whitelist WHERE did_key = $1", did_key ) return row is not None + + async def close(self) -> None: + await self._pool.close() diff --git a/dva-vc-manager/tests/test_routes.py b/dva-vc-manager/tests/test_routes.py index 8d577f28..59a60995 100644 --- a/dva-vc-manager/tests/test_routes.py +++ b/dva-vc-manager/tests/test_routes.py @@ -144,3 +144,19 @@ def test_aov_verify_rejects_malformed_jws_with_400( asyncio.run(whitelist.add(_KNOWN_DID_KEY)) r = client.post("/aov/verify", json={"jws": "not.a.jws.at.all"}) assert r.status_code == 400 + + +def test_lifespan_builds_the_whitelist_repo_at_startup(tmp_path) -> None: + """ + The repo is built once during startup rather than lazily per request. + + The ``client`` fixture overrides ``get_whitelist``, so this is the only + test that exercises the lifespan. + """ + cfg_module.cfg.signing_key_path = str(tmp_path / "key.pem") + cfg_module.cfg.postgres_dsn = "" + app = create_app() + + assert not hasattr(app.state, "whitelist"), "repo must not exist before startup" + with TestClient(app): + assert isinstance(app.state.whitelist, FakeWhitelist) From 9737699809102446d02fcd27e7b2797a816f9d07 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Fri, 31 Jul 2026 23:02:33 +0200 Subject: [PATCH 11/22] refactor(vc): resolve the signing key store via Depends --- .../src/dva_vc_manager/dependencies.py | 18 ++++++++++++ dva-vc-manager/src/dva_vc_manager/keys.py | 5 ++++ dva-vc-manager/src/dva_vc_manager/main.py | 3 +- dva-vc-manager/src/dva_vc_manager/routes.py | 28 ++++++------------- dva-vc-manager/tests/test_routes.py | 11 ++++---- 5 files changed, 40 insertions(+), 25 deletions(-) diff --git a/dva-vc-manager/src/dva_vc_manager/dependencies.py b/dva-vc-manager/src/dva_vc_manager/dependencies.py index 47b1660b..a73010f5 100644 --- a/dva-vc-manager/src/dva_vc_manager/dependencies.py +++ b/dva-vc-manager/src/dva_vc_manager/dependencies.py @@ -6,12 +6,30 @@ from fastapi import Request from .config import cfg +from .keys import SigningKeyStore from .log import get_logger from .whitelist import FakeWhitelist, PgWhitelist, WhitelistRepo logger = get_logger() +def build_key_store() -> SigningKeyStore: + """ + Load the signing key, generating and persisting one if absent. + + Called once, at startup, so a missing directory or bad key file fails + the boot rather than the first request to reach ``/aov/issue``. + """ + store = SigningKeyStore(cfg.signing_key_path) + store.load_or_generate() + return store + + +def get_key_store(request: Request) -> SigningKeyStore: + """Return the signing key store loaded during startup.""" + return request.app.state.key_store + + async def build_whitelist() -> WhitelistRepo: """ Construct the whitelist repo from config. Called once, at startup. diff --git a/dva-vc-manager/src/dva_vc_manager/keys.py b/dva-vc-manager/src/dva_vc_manager/keys.py index 2c388aa5..15d574e8 100644 --- a/dva-vc-manager/src/dva_vc_manager/keys.py +++ b/dva-vc-manager/src/dva_vc_manager/keys.py @@ -51,6 +51,11 @@ def __init__(self, path: str) -> None: self._path = Path(path) self._cached: KeyPair | None = None + @property + def path(self) -> Path: + """The key file backing this store.""" + return self._path + def load_or_generate(self) -> KeyPair: """Return the cached keypair, load from disk, or generate+persist.""" if self._cached is not None: diff --git a/dva-vc-manager/src/dva_vc_manager/main.py b/dva-vc-manager/src/dva_vc_manager/main.py index 19744657..e6aa27f0 100644 --- a/dva-vc-manager/src/dva_vc_manager/main.py +++ b/dva-vc-manager/src/dva_vc_manager/main.py @@ -11,7 +11,7 @@ from fastapi.openapi.utils import get_openapi from .config import cfg -from .dependencies import build_whitelist +from .dependencies import build_key_store, build_whitelist from .log import get_logger, setup_logging from .routes import admin_router, router @@ -52,6 +52,7 @@ def openapi() -> dict[str, Any]: @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Build the whitelist repo (and its connection pool) up front.""" + app.state.key_store = build_key_store() app.state.whitelist = await build_whitelist() try: yield diff --git a/dva-vc-manager/src/dva_vc_manager/routes.py b/dva-vc-manager/src/dva_vc_manager/routes.py index 117a16f2..f0f4e84a 100644 --- a/dva-vc-manager/src/dva_vc_manager/routes.py +++ b/dva-vc-manager/src/dva_vc_manager/routes.py @@ -31,8 +31,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from nacl.signing import VerifyKey -from .config import cfg -from .dependencies import get_whitelist +from .dependencies import get_key_store, get_whitelist from .did_key import did_key_to_public_key from .keys import SigningKeyStore from .models import ( @@ -50,21 +49,12 @@ router = APIRouter() -def _get_key_store() -> SigningKeyStore: - """ - Lazily construct the app-wide signing key store. - - Resolved via FastAPI's dependency system in tests via - ``app.dependency_overrides`` so the test suite can swap in a - key store at a temp-file path. - """ - return SigningKeyStore(cfg.signing_key_path) - - @router.post("/aov/issue", response_model=AovIssueResponse) -async def aov_issue(req: AovIssueRequest) -> AovIssueResponse: +async def aov_issue( + req: AovIssueRequest, + key_store: SigningKeyStore = Depends(get_key_store), +) -> AovIssueResponse: """Issue an AoV JWS credential from the veracity-check results.""" - key_store = _get_key_store() keypair = key_store.load_or_generate() issuer_did_key = key_store.issuer_did_key() @@ -199,9 +189,9 @@ async def whitelist_remove( @admin_router.get("/admin/keys", response_model=OwnKeyDTO) -async def keys_view() -> OwnKeyDTO: - key_store = SigningKeyStore(cfg.signing_key_path) - key_store.load_or_generate() +async def keys_view( + key_store: SigningKeyStore = Depends(get_key_store), +) -> OwnKeyDTO: return OwnKeyDTO( - issuer_did_key=key_store.issuer_did_key(), key_path=cfg.signing_key_path + issuer_did_key=key_store.issuer_did_key(), key_path=str(key_store.path) ) diff --git a/dva-vc-manager/tests/test_routes.py b/dva-vc-manager/tests/test_routes.py index 59a60995..0b8316c6 100644 --- a/dva-vc-manager/tests/test_routes.py +++ b/dva-vc-manager/tests/test_routes.py @@ -12,6 +12,7 @@ from __future__ import annotations import asyncio +from collections.abc import Iterator import pytest from fastapi.testclient import TestClient @@ -36,16 +37,16 @@ def whitelist() -> FakeWhitelist: @pytest.fixture -def client( - whitelist: FakeWhitelist, monkeypatch: pytest.MonkeyPatch, tmp_path -) -> TestClient: - monkeypatch.setenv("DVA_VC_MANAGER_SIGNING_KEY_PATH", str(tmp_path / "key.pem")) +def client(whitelist: FakeWhitelist, tmp_path) -> Iterator[TestClient]: cfg_module.cfg.signing_key_path = str(tmp_path / "key.pem") cfg_module.cfg.postgres_dsn = "" app = create_app() app.dependency_overrides[get_whitelist] = lambda: whitelist - return TestClient(app) + # As a context manager TestClient runs the lifespan, which is what + # loads the signing key and puts it on app.state. + with TestClient(app) as test_client: + yield test_client def _issue_request(): From cc5b59b8a5d6bc7fc3ce7fe559f96cc077406b3b Mon Sep 17 00:00:00 2001 From: bzp99 Date: Mon, 3 Aug 2026 10:28:47 +0200 Subject: [PATCH 12/22] refactor(vc): read settings via pydantic-settings --- dva-vc-manager/README.md | 5 +- dva-vc-manager/src/dva_vc_manager/config.py | 54 ++++++++++++++------- dva-vc-manager/tests/test_routes.py | 18 ++++--- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/dva-vc-manager/README.md b/dva-vc-manager/README.md index 2b9bab03..5f5a2ec2 100644 --- a/dva-vc-manager/README.md +++ b/dva-vc-manager/README.md @@ -53,6 +53,7 @@ uv run dva-vc-manager # boot the service on :8000 |---|---|---| | ``DVA_VC_MANAGER_SIGNING_KEY_PATH`` | ``/data/dva-vc-signing-key.pem`` | Ed25519 key file path (created 0600 on first boot) | | ``DVA_VC_MANAGER_DB_URL`` | *(empty)* | Postgres DSN for the whitelist. Empty → in-memory ``FakeWhitelist`` (verify path fails-closed until admin populates it). | -| ``DVA_VC_MANAGER_API_KEY`` | *(empty)* | Shared-secret bearer for ``/admin/*``. When empty, admin endpoints are disabled. | +| ``DVA_VC_MANAGER_HOST`` | ``0.0.0.0`` | Listen address | | ``DVA_VC_MANAGER_PORT`` | ``8000`` | Listen port | -| ``DVA_VC_MANAGER_LOG_LEVEL`` | ``INFO`` | Standard Python log-level name | +| ``DVA_VC_MANAGER_LOG_LEVEL`` | ``info`` | One of ``critical``, ``error``, ``warning``, ``info``, ``debug`` | +| ``DVA_VC_MANAGER_OPENAPI_FILE`` | ``/app/openapi.yaml`` | Hand-written spec served at ``/swagger``. Missing → FastAPI's generated schema. | diff --git a/dva-vc-manager/src/dva_vc_manager/config.py b/dva-vc-manager/src/dva_vc_manager/config.py index 07c10c32..7ddacb6f 100644 --- a/dva-vc-manager/src/dva_vc_manager/config.py +++ b/dva-vc-manager/src/dva_vc_manager/config.py @@ -2,32 +2,52 @@ from __future__ import annotations -import os -from dataclasses import dataclass +from typing import Literal +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict -@dataclass -class Config: - host: str = os.getenv("DVA_VC_MANAGER_HOST", "0.0.0.0") - port: int = int(os.getenv("DVA_VC_MANAGER_PORT", "8000")) +LogLevel = Literal["critical", "error", "warning", "info", "debug"] - # Level name, lowercased: structlog and uvicorn both take one of - # "critical", "error", "warning", "info", "debug". - log_level: str = os.getenv("DVA_VC_MANAGER_LOG_LEVEL", "info").lower() - # Ed25519 signing key file path. - # Loaded on first use; created and persisted (0600) if missing. - signing_key_path: str = os.getenv( - "DVA_VC_MANAGER_SIGNING_KEY_PATH", "/data/dva-vc-signing-key.pem" +class Config(BaseSettings): + """ + Settings read from the environment, prefixed ``DVA_VC_MANAGER_``. + + Read at instantiation, so tests can build their own ``Config()`` + against a patched environment. + """ + + model_config = SettingsConfigDict( + env_prefix="DVA_VC_MANAGER_", populate_by_name=True ) - # Postgres DSN (whitelist). Required for the production (asyncpg) - # whitelist repo; empty → fall back to in-memory FakeWhitelist. - postgres_dsn: str = os.getenv("DVA_VC_MANAGER_DB_URL", "") + host: str = "0.0.0.0" + port: int = 8000 + + # Both structlog and uvicorn take these names, lowercased. + log_level: LogLevel = "info" + + # Ed25519 signing key file path. + # Loaded at startup; created and persisted (0600) if missing. + signing_key_path: str = "/data/dva-vc-signing-key.pem" + + # Postgres DSN (whitelist); empty → fall back to in-memory FakeWhitelist. + # Predates the env_prefix convention, hence the explicit alias. + postgres_dsn: str = Field("", validation_alias="DVA_VC_MANAGER_DB_URL") # Hand-written OpenAPI spec served at /swagger. # Missing → fall back to FastAPI's auto-generated schema. - openapi_file: str = os.getenv("DVA_VC_MANAGER_OPENAPI_FILE", "/app/openapi.yaml") + openapi_file: str = "/app/openapi.yaml" + + @field_validator("log_level", mode="before") + @classmethod + def _normalise_log_level(cls, value: object) -> object: + """Accept ``INFO`` as well as ``info``, and ``warn`` for ``warning``.""" + if not isinstance(value, str): + return value + value = value.lower() + return "warning" if value == "warn" else value cfg = Config() diff --git a/dva-vc-manager/tests/test_routes.py b/dva-vc-manager/tests/test_routes.py index 0b8316c6..43f9d0f1 100644 --- a/dva-vc-manager/tests/test_routes.py +++ b/dva-vc-manager/tests/test_routes.py @@ -37,9 +37,13 @@ def whitelist() -> FakeWhitelist: @pytest.fixture -def client(whitelist: FakeWhitelist, tmp_path) -> Iterator[TestClient]: - cfg_module.cfg.signing_key_path = str(tmp_path / "key.pem") - cfg_module.cfg.postgres_dsn = "" +def client( + whitelist: FakeWhitelist, tmp_path, monkeypatch: pytest.MonkeyPatch +) -> Iterator[TestClient]: + # setattr (rather than plain assignment) so the global cfg is restored + # afterwards and settings do not leak between tests. + monkeypatch.setattr(cfg_module.cfg, "signing_key_path", str(tmp_path / "key.pem")) + monkeypatch.setattr(cfg_module.cfg, "postgres_dsn", "") app = create_app() app.dependency_overrides[get_whitelist] = lambda: whitelist @@ -147,15 +151,17 @@ def test_aov_verify_rejects_malformed_jws_with_400( assert r.status_code == 400 -def test_lifespan_builds_the_whitelist_repo_at_startup(tmp_path) -> None: +def test_lifespan_builds_the_whitelist_repo_at_startup( + tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: """ The repo is built once during startup rather than lazily per request. The ``client`` fixture overrides ``get_whitelist``, so this is the only test that exercises the lifespan. """ - cfg_module.cfg.signing_key_path = str(tmp_path / "key.pem") - cfg_module.cfg.postgres_dsn = "" + monkeypatch.setattr(cfg_module.cfg, "signing_key_path", str(tmp_path / "key.pem")) + monkeypatch.setattr(cfg_module.cfg, "postgres_dsn", "") app = create_app() assert not hasattr(app.state, "whitelist"), "repo must not exist before startup" From a105a8f245a5451715477bec4f0c2d899b180ff7 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Mon, 3 Aug 2026 12:11:01 +0200 Subject: [PATCH 13/22] docs(vc): remove remaining refs to Kotlin modules --- dva-vc-manager/src/dva_vc_manager/keys.py | 4 ++-- dva-vc-manager/src/dva_vc_manager/whitelist.py | 3 +-- dva-vc-manager/tests/test_did_key.py | 2 +- dva-vc-manager/tests/test_jws.py | 5 ++--- dva-vc-manager/tests/test_keys.py | 2 +- dva-vc-manager/tests/test_whitelist.py | 3 +-- 6 files changed, 8 insertions(+), 11 deletions(-) diff --git a/dva-vc-manager/src/dva_vc_manager/keys.py b/dva-vc-manager/src/dva_vc_manager/keys.py index 15d574e8..2a7a8889 100644 --- a/dva-vc-manager/src/dva_vc_manager/keys.py +++ b/dva-vc-manager/src/dva_vc_manager/keys.py @@ -6,8 +6,8 @@ bytes. The seed is the canonical "private key" representation for Ed25519 in libsodium. -POSIX file permissions 0600 are applied to the key file, mirroring -``SigningKeyStore.kt:107-114``. Parent directories are 0700. +POSIX file permissions 0600 are applied to the key file, and 0700 to +parent directories. """ from __future__ import annotations diff --git a/dva-vc-manager/src/dva_vc_manager/whitelist.py b/dva-vc-manager/src/dva_vc_manager/whitelist.py index 00b3c0e8..55210ec6 100644 --- a/dva-vc-manager/src/dva_vc_manager/whitelist.py +++ b/dva-vc-manager/src/dva_vc_manager/whitelist.py @@ -3,8 +3,7 @@ Two implementations: * :class:`FakeWhitelist` – in-memory list for tests. -* :class:`PgWhitelist` – async-backed PostgreSQL repository via - asyncpg; mirrors ``whitelistMapping.kt:17-20``. +* :class:`PgWhitelist` – async-backed PostgreSQL repository via asyncpg. """ from __future__ import annotations diff --git a/dva-vc-manager/tests/test_did_key.py b/dva-vc-manager/tests/test_did_key.py index b7479179..e09c71af 100644 --- a/dva-vc-manager/tests/test_did_key.py +++ b/dva-vc-manager/tests/test_did_key.py @@ -1,4 +1,4 @@ -"""did:key codec round-trip tests — mirror ``DidKeyTest.kt`` exactly.""" +"""did:key codec round-trip tests.""" from __future__ import annotations diff --git a/dva-vc-manager/tests/test_jws.py b/dva-vc-manager/tests/test_jws.py index f5a2137c..f648eeda 100644 --- a/dva-vc-manager/tests/test_jws.py +++ b/dva-vc-manager/tests/test_jws.py @@ -1,4 +1,4 @@ -"""JWS sign+verify tests — mirror ``JwsSignerTest.kt`` behaviour exactly.""" +"""JWS sign+verify tests.""" from __future__ import annotations @@ -62,8 +62,7 @@ def test_rejection_of_a_clearly_malformed_jws() -> None: def test_payload_shape_includes_context_type_issuer_validfrom_subject() -> None: - """Validate the W3C VC JSON-LD structure — byte-identical to the - Kotlin ``buildAovPayload`` at ``JwsSigner.kt:39-57``.""" + """Validate the W3C VC JSON-LD structure.""" signing_key = SigningKey.generate() jws = sign_jws(_sample_claims(), signing_key, _KNOWN_DID_KEY) payload = decode_payload(jws) diff --git a/dva-vc-manager/tests/test_keys.py b/dva-vc-manager/tests/test_keys.py index 47ce4381..6d84e687 100644 --- a/dva-vc-manager/tests/test_keys.py +++ b/dva-vc-manager/tests/test_keys.py @@ -1,4 +1,4 @@ -"""Signing key store tests — mirror ``SigningKeyStoreTest.kt``.""" +"""Signing key store tests.""" from __future__ import annotations diff --git a/dva-vc-manager/tests/test_whitelist.py b/dva-vc-manager/tests/test_whitelist.py index bdff005f..86473e8a 100644 --- a/dva-vc-manager/tests/test_whitelist.py +++ b/dva-vc-manager/tests/test_whitelist.py @@ -1,5 +1,4 @@ -"""Whitelist repository tests — mirror the behaviour Kotlin -``AdminRoutesTest.kt`` covers (add, list, delete).""" +"""Whitelist repository tests — add, list, delete.""" from __future__ import annotations From 9ed42c0d5af36a8ca926e931247fac85cd690212 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Mon, 3 Aug 2026 12:14:31 +0200 Subject: [PATCH 14/22] refactor(vc): improve DTO handling --- dva-vc-manager/src/dva_vc_manager/models.py | 36 +++++++-------------- dva-vc-manager/src/dva_vc_manager/routes.py | 20 +++++------- 2 files changed, 20 insertions(+), 36 deletions(-) diff --git a/dva-vc-manager/src/dva_vc_manager/models.py b/dva-vc-manager/src/dva_vc_manager/models.py index 19e18bf1..ab08bbd7 100644 --- a/dva-vc-manager/src/dva_vc_manager/models.py +++ b/dva-vc-manager/src/dva_vc_manager/models.py @@ -1,11 +1,9 @@ """ HTTP request/response models for /aov/issue and /aov/verify. -The JSON shape is kept byte-compatible with the Kotlin ``dva-api`` -``/attestation`` response so the PDC client -(``dataspace-connector-1.10.2/src/libs/third-party/dva.ts``) sees no -contract change when the JWS is issued by this Python service instead -of the inlined Kotlin signer. +The AoV endpoints speak ``camelCase`` on the wire while the Python +attribute names stay ``snake_case``. ``populate_by_name`` keeps +``snake_case`` accepted on input too, which is what the tests send. """ from __future__ import annotations @@ -14,18 +12,10 @@ from typing import Optional from uuid import UUID -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict +from pydantic.alias_generators import to_camel -# JSON keys for these request/response models are byte-identical with the -# Kotlin ``dva-api`` DTOs (``route/aovRoutes.kt:39-57`` and -# ``AoVDTOs.kt``). Kotlin property names are ``camelCase`` (e.g. ``vcId``, -# ``issuerDidKey``); pydantic's Python-native attribute names stay -# ``snake_case`` but the wire format MUST be ``camelCase`` so the Kotlin -# client/server round-trip works without contract drift. We add aliases -# to each advanced-name field and let ``populate_by_name=True`` keep -# snake_case accepted on the Python side (for unit tests and direct -# curl). -_CAMEL = ConfigDict(populate_by_name=True) +_CAMEL = ConfigDict(alias_generator=to_camel, populate_by_name=True) class EvaluationResultDTO(BaseModel): @@ -49,16 +39,14 @@ class AovIssueRequest(BaseModel): model_config = _CAMEL - valid_since: str = Field(..., alias="validSince") + valid_since: str subject: str - issuer_id: str = Field(..., alias="issuerId") - record_id: str = Field(..., alias="recordId") - contract_id: str = Field(..., alias="contractId") - data_exchange_id: str = Field(..., alias="dataExchangeId") + issuer_id: str + record_id: str + contract_id: str + data_exchange_id: str payload: str - evaluation_results: list[EvaluationResultDTO] = Field( - ..., alias="evaluationResults" - ) + evaluation_results: list[EvaluationResultDTO] class AovIssueResponse(BaseModel): diff --git a/dva-vc-manager/src/dva_vc_manager/routes.py b/dva-vc-manager/src/dva_vc_manager/routes.py index f0f4e84a..9d268302 100644 --- a/dva-vc-manager/src/dva_vc_manager/routes.py +++ b/dva-vc-manager/src/dva_vc_manager/routes.py @@ -44,7 +44,7 @@ WhitelistEntryDTO, ) from .signing import AovClaims, decode_payload, sign_jws, verify_jws -from .whitelist import WhitelistRepo +from .whitelist import WhitelistEntry, WhitelistRepo router = APIRouter() @@ -58,9 +58,9 @@ async def aov_issue( keypair = key_store.load_or_generate() issuer_did_key = key_store.issuer_did_key() - # AoVClaims is passed in snake_case and build_aov_payload embeds these - # values into the VC JSON-LD payload (credentialSubject keys are snake_case - # to match the existing Kotlin implementation) + # build_aov_payload embeds these values into the VC JSON-LD payload. + # Note the credentialSubject keys stay snake_case even though the + # request body is camelCase -- see the models module docstring. claims = AovClaims( vc_id=str(uuid4()), valid_since=req.valid_since, @@ -153,11 +153,8 @@ async def aov_verify( @admin_router.get("/admin/whitelist", response_model=list[WhitelistEntryDTO]) async def whitelist_list( whitelist: WhitelistRepo = Depends(get_whitelist), -) -> list[WhitelistEntryDTO]: - entries = await whitelist.all() - return [ - WhitelistEntryDTO(id=e.id, did_key=e.did_key, label=e.label) for e in entries - ] +) -> list[WhitelistEntry]: + return await whitelist.all() @admin_router.post( @@ -168,9 +165,8 @@ async def whitelist_list( async def whitelist_add( req: WhitelistAddRequest, whitelist: WhitelistRepo = Depends(get_whitelist), -) -> WhitelistEntryDTO: - entry = await whitelist.add(req.did_key, req.label) - return WhitelistEntryDTO(id=entry.id, did_key=entry.did_key, label=entry.label) +) -> WhitelistEntry: + return await whitelist.add(req.did_key, req.label) @admin_router.delete( From 27c961b1cb953f9acd26a940b7eea6059ccf8041 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Mon, 3 Aug 2026 19:42:05 +0200 Subject: [PATCH 15/22] fix(vc): reject non-object JWS payloads --- dva-vc-manager/src/dva_vc_manager/routes.py | 25 +++++++++------- dva-vc-manager/src/dva_vc_manager/signing.py | 31 ++++++++++++++------ dva-vc-manager/tests/test_jws.py | 10 +++++-- dva-vc-manager/tests/test_routes.py | 16 ++++++++++ 4 files changed, 61 insertions(+), 21 deletions(-) diff --git a/dva-vc-manager/src/dva_vc_manager/routes.py b/dva-vc-manager/src/dva_vc_manager/routes.py index 9d268302..994aa433 100644 --- a/dva-vc-manager/src/dva_vc_manager/routes.py +++ b/dva-vc-manager/src/dva_vc_manager/routes.py @@ -43,7 +43,14 @@ WhitelistAddRequest, WhitelistEntryDTO, ) -from .signing import AovClaims, decode_payload, sign_jws, verify_jws +from .signing import ( + AovClaims, + MalformedJws, + decode_payload, + sign_jws, + split_jws, + verify_jws, +) from .whitelist import WhitelistEntry, WhitelistRepo router = APIRouter() @@ -88,12 +95,10 @@ async def aov_verify( """ # 1. Structural check: must be a 3-part compact JWS. - parts = req.jws.split(".") - if len(parts) != 3: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail="Compact JWS must have 3 dot-separated parts", - ) + try: + split_jws(req.jws) + except MalformedJws as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(e)) from e # 2. Whitelist must be non-empty. Checked early so an operator who # has not configured any trusted issuers gets a clear reason rather @@ -110,7 +115,7 @@ async def aov_verify( # is reported as a verification failure, not a 400. try: payload = decode_payload(req.jws) - except Exception as e: + except ValueError as e: return AovVerifyResponse(verified=False, reason=f"malformed JWS payload: {e}") issuer_did_key = payload.get("issuer") @@ -125,7 +130,7 @@ async def aov_verify( # 5. Derive the public key from the whitelist record's did:key. try: public_key = did_key_to_public_key(entry.did_key) - except Exception as e: + except ValueError as e: return AovVerifyResponse( verified=False, reason=f"whitelist entry contains invalid did:key: {e}", @@ -135,7 +140,7 @@ async def aov_verify( # signature does not verify returns verified=false. try: ok = verify_jws(req.jws, VerifyKey(bytes(public_key))) - except Exception as e: + except ValueError as e: return AovVerifyResponse(verified=False, reason=f"signature check failed: {e}") if not ok: diff --git a/dva-vc-manager/src/dva_vc_manager/signing.py b/dva-vc-manager/src/dva_vc_manager/signing.py index bcb15cf8..80a7bb7d 100644 --- a/dva-vc-manager/src/dva_vc_manager/signing.py +++ b/dva-vc-manager/src/dva_vc_manager/signing.py @@ -38,6 +38,18 @@ class AovClaims(BaseModel): model_config = {"populate_by_name": True} +class MalformedJws(ValueError): + """The string is not a well-formed compact JWS.""" + + +def split_jws(jws: str) -> tuple[str, str, str]: + """Split a compact JWS into its header, payload and signature segments.""" + parts = jws.split(".") + if len(parts) != 3: + raise MalformedJws("Compact JWS must have 3 dot-separated parts") + return parts[0], parts[1], parts[2] + + def _b64url(data: bytes) -> str: """Standard JWS base64url **without** padding (per RFC 7515 §2.2.2).""" return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") @@ -93,11 +105,9 @@ def sign_jws(claims: AovClaims, signing_key: SigningKey, issuer_did_key: str) -> def verify_jws(jws: str, public_key: VerifyKey) -> bool: """Verify a compact JWS.""" - parts = jws.split(".") - if len(parts) != 3: - raise ValueError("Compact JWS must have 3 dot-separated parts") - signing_input = f"{parts[0]}.{parts[1]}".encode("ascii") - signature = _b64url_decode(parts[2]) + header_b64, payload_b64, signature_b64 = split_jws(jws) + signing_input = f"{header_b64}.{payload_b64}".encode("ascii") + signature = _b64url_decode(signature_b64) try: public_key.verify(signing_input, signature) return True @@ -107,7 +117,10 @@ def verify_jws(jws: str, public_key: VerifyKey) -> bool: def decode_payload(jws: str) -> dict[str, Any]: """Decode (without verifying) the payload middle segment of a JWS.""" - parts = jws.split(".") - if len(parts) != 3: - raise ValueError("Compact JWS must have 3 dot-separated parts") - return json.loads(_b64url_decode(parts[1])) + _, payload_b64, _ = split_jws(jws) + payload = json.loads(_b64url_decode(payload_b64)) + if not isinstance(payload, dict): + raise MalformedJws( + f"JWS payload must be a JSON object, got {type(payload).__name__}" + ) + return payload diff --git a/dva-vc-manager/tests/test_jws.py b/dva-vc-manager/tests/test_jws.py index f648eeda..b09f197e 100644 --- a/dva-vc-manager/tests/test_jws.py +++ b/dva-vc-manager/tests/test_jws.py @@ -5,7 +5,13 @@ import pytest from nacl.signing import SigningKey, VerifyKey -from dva_vc_manager.signing import AovClaims, decode_payload, sign_jws, verify_jws +from dva_vc_manager.signing import ( + AovClaims, + MalformedJws, + decode_payload, + sign_jws, + verify_jws, +) def _sample_claims() -> AovClaims: @@ -57,7 +63,7 @@ def test_rejection_of_a_clearly_malformed_jws() -> None: signing_key = SigningKey.generate() public_key = VerifyKey(bytes(signing_key.verify_key)) - with pytest.raises(Exception): + with pytest.raises(MalformedJws): verify_jws("not.a.jws.at.all", public_key) diff --git a/dva-vc-manager/tests/test_routes.py b/dva-vc-manager/tests/test_routes.py index 43f9d0f1..b2bc5c99 100644 --- a/dva-vc-manager/tests/test_routes.py +++ b/dva-vc-manager/tests/test_routes.py @@ -12,6 +12,7 @@ from __future__ import annotations import asyncio +import base64 from collections.abc import Iterator import pytest @@ -151,6 +152,21 @@ def test_aov_verify_rejects_malformed_jws_with_400( assert r.status_code == 400 +def test_aov_verify_rejects_non_object_payload( + client: TestClient, whitelist: FakeWhitelist +) -> None: + """A JWS whose payload is valid JSON but not an object must not 500.""" + asyncio.run(whitelist.add(_KNOWN_DID_KEY)) + header = base64.urlsafe_b64encode(b'{"alg":"EdDSA"}').rstrip(b"=").decode("ascii") + body = base64.urlsafe_b64encode(b"[1,2,3]").rstrip(b"=").decode("ascii") + + r = client.post("/aov/verify", json={"jws": f"{header}.{body}.c2ln"}) + + assert r.status_code == 200, r.text + assert r.json()["verified"] is False + assert "JSON object" in r.json()["reason"] + + def test_lifespan_builds_the_whitelist_repo_at_startup( tmp_path, monkeypatch: pytest.MonkeyPatch ) -> None: From d055f666852183b90d889362b45ef16df1a54d97 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Mon, 3 Aug 2026 21:04:45 +0200 Subject: [PATCH 16/22] refactor(vc): use VerifyKey in the did:key codec --- dva-vc-manager/src/dva_vc_manager/did_key.py | 12 ++++++------ dva-vc-manager/src/dva_vc_manager/keys.py | 7 +------ dva-vc-manager/src/dva_vc_manager/routes.py | 3 +-- dva-vc-manager/tests/test_did_key.py | 5 ++--- 4 files changed, 10 insertions(+), 17 deletions(-) diff --git a/dva-vc-manager/src/dva_vc_manager/did_key.py b/dva-vc-manager/src/dva_vc_manager/did_key.py index 3af3af71..8c0b695f 100644 --- a/dva-vc-manager/src/dva_vc_manager/did_key.py +++ b/dva-vc-manager/src/dva_vc_manager/did_key.py @@ -17,7 +17,7 @@ from __future__ import annotations import base58 -from nacl.public import PublicKey +from nacl.signing import VerifyKey ED25519_RAW_SIZE = 32 ED25519_MULTICODEC_PREFIX = b"\xed\x01" @@ -25,8 +25,8 @@ DID_KEY_SCHEME = "did:key:" -def public_key_to_did_key(public_key: PublicKey) -> str: - """Encode a PyNaCl Ed25519 PublicKey into a ``did:key`` identifier.""" +def public_key_to_did_key(public_key: VerifyKey) -> str: + """Encode a PyNaCl Ed25519 VerifyKey into a ``did:key`` identifier.""" raw = bytes(public_key) if len(raw) != ED25519_RAW_SIZE: raise ValueError(f"Ed25519 public key must be exactly 32 bytes, got {len(raw)}") @@ -38,8 +38,8 @@ def public_key_to_did_key(public_key: PublicKey) -> str: ) -def did_key_to_public_key(did_key: str) -> PublicKey: - """Decode a ``did:key`` Ed25519 identifier back into a PyNaCl PublicKey.""" +def did_key_to_public_key(did_key: str) -> VerifyKey: + """Decode a ``did:key`` Ed25519 identifier back into a PyNaCl VerifyKey.""" if not did_key.startswith(DID_KEY_SCHEME): raise ValueError(f"not a did:key identifier: {did_key}") multibase = did_key.removeprefix(DID_KEY_SCHEME) @@ -58,4 +58,4 @@ def did_key_to_public_key(did_key: str) -> PublicKey: f"multicodec prefix 0x{decoded[0]:02x}{decoded[1]:02x} " "is not the Ed25519 prefix 0xed01" ) - return PublicKey(decoded[2:]) + return VerifyKey(decoded[2:]) diff --git a/dva-vc-manager/src/dva_vc_manager/keys.py b/dva-vc-manager/src/dva_vc_manager/keys.py index 2a7a8889..01327b96 100644 --- a/dva-vc-manager/src/dva_vc_manager/keys.py +++ b/dva-vc-manager/src/dva_vc_manager/keys.py @@ -16,7 +16,6 @@ import os from pathlib import Path -from nacl.public import PublicKey from nacl.signing import SigningKey, VerifyKey from .did_key import public_key_to_did_key @@ -39,10 +38,6 @@ def private(self) -> SigningKey: def public(self) -> VerifyKey: return self._public - def public_key(self) -> PublicKey: - """Return the PyNaCl PublicKey (needed by did_key codec).""" - return PublicKey(bytes(self._public)) - class SigningKeyStore: """Persistent Ed25519 keypair store backed by a filesystem path.""" @@ -108,4 +103,4 @@ def issuer_did_key(self) -> str: raise RuntimeError( "SigningKeyStore.load_or_generate() must be called before issuer_did_key()" ) - return public_key_to_did_key(self._cached.public_key()) + return public_key_to_did_key(self._cached.public) diff --git a/dva-vc-manager/src/dva_vc_manager/routes.py b/dva-vc-manager/src/dva_vc_manager/routes.py index 994aa433..4907d593 100644 --- a/dva-vc-manager/src/dva_vc_manager/routes.py +++ b/dva-vc-manager/src/dva_vc_manager/routes.py @@ -29,7 +29,6 @@ from uuid import uuid4 from fastapi import APIRouter, Depends, HTTPException, status -from nacl.signing import VerifyKey from .dependencies import get_key_store, get_whitelist from .did_key import did_key_to_public_key @@ -139,7 +138,7 @@ async def aov_verify( # 6. Verify the Ed25519 signature. A structurally-valid JWS whose # signature does not verify returns verified=false. try: - ok = verify_jws(req.jws, VerifyKey(bytes(public_key))) + ok = verify_jws(req.jws, public_key) except ValueError as e: return AovVerifyResponse(verified=False, reason=f"signature check failed: {e}") diff --git a/dva-vc-manager/tests/test_did_key.py b/dva-vc-manager/tests/test_did_key.py index e09c71af..b05c0973 100644 --- a/dva-vc-manager/tests/test_did_key.py +++ b/dva-vc-manager/tests/test_did_key.py @@ -2,7 +2,6 @@ from __future__ import annotations -from nacl.public import PublicKey from nacl.signing import SigningKey from dva_vc_manager.did_key import ( @@ -13,7 +12,7 @@ def test_ed25519_key_round_trips_through_did_key() -> None: signing_key = SigningKey.generate() - pub = PublicKey(bytes(signing_key.verify_key)) + pub = signing_key.verify_key did_key = public_key_to_did_key(pub) round_tripped_pub = did_key_to_public_key(did_key) @@ -33,7 +32,7 @@ def test_known_spec_vector() -> None: def test_starts_with_did_key_z6mk() -> None: signing_key = SigningKey.generate() - pub = PublicKey(bytes(signing_key.verify_key)) + pub = signing_key.verify_key did_key = public_key_to_did_key(pub) assert did_key.startswith("did:key:z6Mk"), ( "did:key identifier must start with 'did:key:z6Mk'" From b7b519b6f6359fcc9318723c26e1350cfc6fe4f6 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Thu, 6 Aug 2026 09:50:08 +0200 Subject: [PATCH 17/22] ci(vc): improve docker build setup --- dva-vc-manager/Dockerfile | 62 ++++++++++++----------- dva-vc-manager/src/dva_vc_manager/main.py | 54 ++++++++------------ 2 files changed, 54 insertions(+), 62 deletions(-) diff --git a/dva-vc-manager/Dockerfile b/dva-vc-manager/Dockerfile index fe1f8750..e2cd42b4 100644 --- a/dva-vc-manager/Dockerfile +++ b/dva-vc-manager/Dockerfile @@ -1,53 +1,55 @@ FROM python:3.12-slim AS build -# Install uv (mirrors dva-processing/Dockerfile style) +# Install uv COPY --from=ghcr.io/astral-sh/uv:0.7 /uv /uvx /bin/ +# Set working directory WORKDIR /app/ -# Install dependencies (cached layer) +# Install dependencies RUN \ - --mount=type=cache,target=/root/.cache/uv2 \ + --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=./dva-vc-manager/uv.lock,target=uv.lock \ --mount=type=bind,source=./dva-vc-manager/pyproject.toml,target=pyproject.toml \ - uv pip install --system \ - "fastapi~=0.136.3" \ - "asyncpg>=0.30.0" \ - "base58>=2.1.1" \ - "pydantic>=2.10.6" \ - "pydantic-settings>=2.5.0" \ - "pynacl>=1.5.0" \ - "pyyaml>=6.0" \ - "structlog>=25.1.0" \ - "uvicorn>=0.34.3" + uv sync \ + --frozen \ + --no-install-project \ + --no-dev \ + --compile-bytecode \ + --no-editable # Copy app files COPY ./dva-vc-manager/ /app/ +# Sync project +RUN \ + --mount=type=cache,target=/root/.cache/uv \ + uv sync \ + --frozen \ + --no-dev \ + --compile-bytecode \ + --no-editable + # ---------------------------------------------------------------- FROM python:3.12-slim -# netcat for healthcheck +# Install netcat for healthcheck RUN apt-get update && \ - apt-get install -y --no-install-recommends netcat-openbsd=1.* \ + apt-get install -y \ + --no-install-recommends \ + netcat-openbsd=1.* \ && rm -rf /var/lib/apt/lists/ -# uvicorn binary -COPY --from=build /usr/local/bin/uvicorn /usr/local/bin/uvicorn - -# Copy installed packages + source from build stage -COPY --from=build --chown=app:app /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages -COPY --from=build --chown=app:app /app/src /app/src -COPY --from=build --chown=app:app /app/pyproject.toml /app/pyproject.toml +# Copy built project +COPY --from=build /app/.venv/ /app/.venv/ -WORKDIR /app +# Hand-written OpenAPI spec -- served at /swagger/openapi.json +COPY ./docs/spec/dva-vc-manager.yaml /app/openapi.yaml -ENV PYTHONPATH=/app/src \ +ENV DVA_VC_MANAGER_OPENAPI_FILE=/app/openapi.yaml \ PYTHONUNBUFFERED=1 -# Hand-written OpenAPI spec — served at /swagger/openapi.yaml -COPY ./docs/spec/dva-vc-manager.yaml /app/openapi.yaml -ENV DVA_VC_MANAGER_OPENAPI_FILE=/app/openapi.yaml - -ENTRYPOINT ["uvicorn", "dva_vc_manager.main:app", "--host", "0.0.0.0", "--port", "8000"] +# Run app; host, port and log level all come from the environment +ENTRYPOINT ["/app/.venv/bin/dva-vc-manager"] -EXPOSE 8000/tcp \ No newline at end of file +EXPOSE 8000/tcp diff --git a/dva-vc-manager/src/dva_vc_manager/main.py b/dva-vc-manager/src/dva_vc_manager/main.py index e6aa27f0..cc7e434a 100644 --- a/dva-vc-manager/src/dva_vc_manager/main.py +++ b/dva-vc-manager/src/dva_vc_manager/main.py @@ -3,7 +3,7 @@ from __future__ import annotations from contextlib import asynccontextmanager -from typing import Any, AsyncIterator, Callable +from typing import Any, AsyncIterator import uvicorn import yaml @@ -18,35 +18,23 @@ logger = get_logger() -def _spec_loader(app: FastAPI) -> Callable[[], dict[str, Any]]: - """ - Build the ``app.openapi`` callable serving the hand-written spec. - - FastAPI caches the result in ``app.openapi_schema`` and renders both the - Swagger UI and ReDoc pages from it, so the spec is read from disk once. - """ - - def openapi() -> dict[str, Any]: - if app.openapi_schema: - return app.openapi_schema - try: - with open(cfg.openapi_file, "r", encoding="utf-8") as fh: - app.openapi_schema = yaml.safe_load(fh) - except FileNotFoundError: - logger.warning( - "OpenAPI spec not found, falling back to the auto-generated schema; " - "set DVA_VC_MANAGER_OPENAPI_FILE to the hand-written spec", - openapi_file=cfg.openapi_file, - ) - app.openapi_schema = get_openapi( - title=app.title, - version=app.version, - description=app.description, - routes=app.routes, - ) - return app.openapi_schema - - return openapi +def _load_openapi_schema(app: FastAPI) -> dict[str, Any]: + """Return the hand-written spec, or FastAPI's generated one if absent.""" + try: + with open(cfg.openapi_file, encoding="utf-8") as fh: + return yaml.safe_load(fh) + except FileNotFoundError: + logger.warning( + "OpenAPI spec not found, falling back to the auto-generated schema; " + "set DVA_VC_MANAGER_OPENAPI_FILE to the hand-written spec", + openapi_file=cfg.openapi_file, + ) + return get_openapi( + title=app.title, + version=app.version, + description=app.description, + routes=app.routes, + ) @asynccontextmanager @@ -73,14 +61,16 @@ def create_app() -> FastAPI: ), version="0.1.0", # Docs pages are FastAPI's own; the schema behind them is the - # hand-written spec installed as app.openapi below. + # hand-written spec assigned below. docs_url="/swagger", redoc_url="/redoc", openapi_url="/swagger/openapi.json", ) app.include_router(router) app.include_router(admin_router) - app.openapi = _spec_loader(app) + # Populating openapi_schema is what app.openapi() consults first, so + # Swagger UI and ReDoc both render the hand-written spec. + app.openapi_schema = _load_openapi_schema(app) return app From 78e921f898975d735cc0b178d44a7a94d1178f1e Mon Sep 17 00:00:00 2001 From: bzp99 Date: Thu, 6 Aug 2026 09:53:47 +0200 Subject: [PATCH 18/22] ci: add global dockerignore file --- .dockerignore | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..b82d7f28 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +# Docker reads .dockerignore from the build-context root only. The service +# Dockerfiles reference .//... paths, so the context is this +# directory and per-service .dockerignore files have no effect. + +# Generic +.git/ +**/.gitignore +**/Dockerfile + +# JVM projects +**/.gradle/ +**/.idea/ +**/.kotlin/ +**/build/ + + +# Python projects +**/.venv/ +**/__pycache__/ +**/*.pyc +**/.pytest_cache/ +**/*.egg-info/ + +# Node projects +**/node_modules/ From cdcb1524ea70b6939d57343ea3e404ac04868e2f Mon Sep 17 00:00:00 2001 From: bzp99 Date: Thu, 6 Aug 2026 10:14:13 +0200 Subject: [PATCH 19/22] refactor(vc): delegate did:key encoding to multiformats --- dva-vc-manager/pyproject.toml | 2 +- dva-vc-manager/src/dva_vc_manager/did_key.py | 57 +++++++-------- dva-vc-manager/tests/test_did_key.py | 32 +++++++++ dva-vc-manager/uv.lock | 76 ++++++++++++++++++-- 4 files changed, 127 insertions(+), 40 deletions(-) diff --git a/dva-vc-manager/pyproject.toml b/dva-vc-manager/pyproject.toml index 96dffebb..26065637 100644 --- a/dva-vc-manager/pyproject.toml +++ b/dva-vc-manager/pyproject.toml @@ -8,8 +8,8 @@ license = "Apache-2.0" requires-python = ">=3.10" dependencies = [ "asyncpg>=0.30.0", - "base58>=2.1.1", "fastapi~=0.136.3", + "multiformats>=0.3.1", "pydantic>=2.10.6", "pydantic-settings>=2.5.0", "pynacl>=1.5.0", diff --git a/dva-vc-manager/src/dva_vc_manager/did_key.py b/dva-vc-manager/src/dva_vc_manager/did_key.py index 8c0b695f..88ee2672 100644 --- a/dva-vc-manager/src/dva_vc_manager/did_key.py +++ b/dva-vc-manager/src/dva_vc_manager/did_key.py @@ -1,13 +1,8 @@ """ ``did:key`` codec for Ed25519 keys. -* The Ed25519 public key is encoded as 32 raw bytes (RFC 8032). -* Prefixed with the Ed25519 multicodec prefix ``0xed 0x01``. -* Then ``multibase(base58btc(...))`` – prefixed with the ``z`` character - for base58btc. -* Finally wrapped in ``did:key:``. - -This is exactly the W3C did:key specification – no custom cryptography. +The multibase/multicodec encoding is delegated to ``multiformats``; this +module only adds the ``did:key:`` scheme and the Ed25519 constraints. Reference: - https://w3c-ccg.github.io/did-method-key/ @@ -16,46 +11,42 @@ from __future__ import annotations -import base58 +from multiformats import multibase, multicodec from nacl.signing import VerifyKey ED25519_RAW_SIZE = 32 -ED25519_MULTICODEC_PREFIX = b"\xed\x01" -MULTIBASE_BASE58BTC_PREFIX = "z" +ED25519_MULTICODEC = "ed25519-pub" +MULTIBASE_BASE58BTC = "base58btc" DID_KEY_SCHEME = "did:key:" def public_key_to_did_key(public_key: VerifyKey) -> str: """Encode a PyNaCl Ed25519 VerifyKey into a ``did:key`` identifier.""" raw = bytes(public_key) + # multicodec.wrap does not check the payload length for us. if len(raw) != ED25519_RAW_SIZE: raise ValueError(f"Ed25519 public key must be exactly 32 bytes, got {len(raw)}") - multicodec = ED25519_MULTICODEC_PREFIX + raw - return ( - DID_KEY_SCHEME - + MULTIBASE_BASE58BTC_PREFIX - + base58.b58encode(multicodec).decode("ascii") - ) + wrapped = multicodec.wrap(ED25519_MULTICODEC, raw) + return DID_KEY_SCHEME + multibase.encode(wrapped, MULTIBASE_BASE58BTC) def did_key_to_public_key(did_key: str) -> VerifyKey: """Decode a ``did:key`` Ed25519 identifier back into a PyNaCl VerifyKey.""" if not did_key.startswith(DID_KEY_SCHEME): raise ValueError(f"not a did:key identifier: {did_key}") - multibase = did_key.removeprefix(DID_KEY_SCHEME) - if not multibase.startswith(MULTIBASE_BASE58BTC_PREFIX): - raise ValueError( - f"only base58btc multibase ('z') is supported, got: {multibase}" - ) - decoded = base58.b58decode(multibase[1:]) - if len(decoded) != len(ED25519_MULTICODEC_PREFIX) + ED25519_RAW_SIZE: - raise ValueError( - f"decoded multicodec is {len(decoded)} bytes, " - f"expected {len(ED25519_MULTICODEC_PREFIX) + ED25519_RAW_SIZE}" - ) - if decoded[:2] != ED25519_MULTICODEC_PREFIX: - raise ValueError( - f"multicodec prefix 0x{decoded[0]:02x}{decoded[1]:02x} " - "is not the Ed25519 prefix 0xed01" - ) - return VerifyKey(decoded[2:]) + + try: + base, decoded = multibase.decode_raw(did_key.removeprefix(DID_KEY_SCHEME)) + codec, raw = multicodec.unwrap(decoded) + except KeyError as e: + # multiformats reports unknown multibase/multicodec prefixes with + # KeyError subclasses; callers here expect malformed input to raise + # ValueError, as everything else in this module does. + raise ValueError(f"unsupported did:key encoding: {did_key}") from e + + if base.name != MULTIBASE_BASE58BTC: + raise ValueError(f"did:key must use base58btc multibase, got {base.name}") + if codec.name != ED25519_MULTICODEC: + raise ValueError(f"did:key must be {ED25519_MULTICODEC}, got {codec.name}") + + return VerifyKey(raw) diff --git a/dva-vc-manager/tests/test_did_key.py b/dva-vc-manager/tests/test_did_key.py index b05c0973..a66d796c 100644 --- a/dva-vc-manager/tests/test_did_key.py +++ b/dva-vc-manager/tests/test_did_key.py @@ -2,6 +2,8 @@ from __future__ import annotations +import pytest +from multiformats import multibase, multicodec from nacl.signing import SigningKey from dva_vc_manager.did_key import ( @@ -37,3 +39,33 @@ def test_starts_with_did_key_z6mk() -> None: assert did_key.startswith("did:key:z6Mk"), ( "did:key identifier must start with 'did:key:z6Mk'" ) + + +def _did_key(codec: str, base: str = "base58btc", raw: bytes | None = None) -> str: + raw = raw if raw is not None else bytes(SigningKey.generate().verify_key) + return "did:key:" + multibase.encode(multicodec.wrap(codec, raw), base) + + +@pytest.mark.parametrize( + "bad_did_key", + [ + pytest.param("did:web:example.com", id="not_a_did_key"), + pytest.param("did:key:", id="empty"), + pytest.param("did:key:Q6MkhaXgBZDvotDkL5257fai", id="unknown_multibase"), + pytest.param("did:key:z6MkO0Il", id="invalid_base58_chars"), + pytest.param(_did_key("ed25519-pub", base="base16"), id="not_base58btc"), + pytest.param(_did_key("x25519-pub"), id="x25519_not_ed25519"), + pytest.param(_did_key("secp256k1-pub"), id="secp256k1_not_ed25519"), + pytest.param(_did_key("ed25519-pub", raw=b"short"), id="wrong_key_length"), + ], +) +def test_malformed_did_key_raises_value_error(bad_did_key: str) -> None: + """ + Malformed input must raise ValueError, never KeyError. + + ``multiformats`` signals unknown multibase/multicodec prefixes with + KeyError subclasses, which ``aov_verify`` does not catch -- those would + surface as HTTP 500 rather than ``verified: false``. + """ + with pytest.raises(ValueError): + did_key_to_public_key(bad_did_key) diff --git a/dva-vc-manager/uv.lock b/dva-vc-manager/uv.lock index 1711c7b6..7cdfd2bd 100644 --- a/dva-vc-manager/uv.lock +++ b/dva-vc-manager/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] [[package]] name = "annotated-doc" @@ -112,12 +116,17 @@ wheels = [ ] [[package]] -name = "base58" -version = "2.1.1" +name = "bases" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7f/45/8ae61209bb9015f516102fa559a2914178da1d5868428bd86a1b4421141d/base58-2.1.1.tar.gz", hash = "sha256:c5d0cb3f5b6e81e8e35da5754388ddcc6d0d14b6c6a132cb93d69ed580a7278c", size = 6528, upload-time = "2021-10-30T22:12:17.858Z" } +dependencies = [ + { name = "typing-extensions" }, + { name = "typing-validation", version = "1.2.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "typing-validation", version = "2.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/8d/105bca352e2fc5f1ee07f425ec296aa680525aac7f197ef135ea057902ac/bases-0.3.0.tar.gz", hash = "sha256:70f04a4a45d63245787f9e89095ca11042685b6b64b542ad916575ba3ccd1570", size = 789978, upload-time = "2023-12-18T16:57:17.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/45/ec96b29162a402fc4c1c5512d114d7b3787b9d1c2ec241d9568b4816ee23/base58-2.1.1-py3-none-any.whl", hash = "sha256:11a36f4d3ce51dfc1043f3218591ac4eb1ceb172919cebe05b52a5bcc8d245c2", size = 5621, upload-time = "2021-10-30T22:12:16.658Z" }, + { url = "https://files.pythonhosted.org/packages/b4/15/7bcf28a3f971e1b0523fab46ae3ca935a589249544187558e5a8e70af393/bases-0.3.0-py3-none-any.whl", hash = "sha256:a2fef3366f3e522ff473d2e95c21523fe8e44251038d5c6150c01481585ebf5b", size = 36053, upload-time = "2023-12-18T16:57:14.253Z" }, ] [[package]] @@ -266,8 +275,8 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "asyncpg" }, - { name = "base58" }, { name = "fastapi" }, + { name = "multiformats" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pynacl" }, @@ -287,8 +296,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "asyncpg", specifier = ">=0.30.0" }, - { name = "base58", specifier = ">=2.1.1" }, { name = "fastapi", specifier = "~=0.136.3" }, + { name = "multiformats", specifier = ">=0.3.1" }, { name = "pydantic", specifier = ">=2.10.6" }, { name = "pydantic-settings", specifier = ">=2.5.0" }, { name = "pynacl", specifier = ">=1.5.0" }, @@ -388,6 +397,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "multiformats" +version = "0.3.1.post4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bases" }, + { name = "multiformats-config" }, + { name = "typing-extensions" }, + { name = "typing-validation", version = "1.2.12", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, + { name = "typing-validation", version = "2.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/41/2efc6e99fa2ed9f1a47fbfed5d124215e35db0a849585db72eeb1490de0e/multiformats-0.3.1.post4.tar.gz", hash = "sha256:d00074fdbc7d603c2084b4c38fa17bbc28173cf2750f51f46fbbc5c4d5605fbb", size = 826017, upload-time = "2023-12-20T14:18:00.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/02/0eff41b136c6723441d052c61c9dae36b86b3ae68ec064813445580222a6/multiformats-0.3.1.post4-py3-none-any.whl", hash = "sha256:5b1d61bd8275c9e817bdbee38dbd501b26629011962ee3c86c46e7ccd0b14129", size = 57148, upload-time = "2023-12-20T14:17:58.576Z" }, +] + +[[package]] +name = "multiformats-config" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "multiformats" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/09/ccb6867c2d6c6de98d1d285d8a3a2103fdf452c2fef5019bb3d8ac9938d9/multiformats-config-0.3.1.tar.gz", hash = "sha256:7eaa80ef5d9c5ee9b86612d21f93a087c4a655cbcb68960457e61adbc62b47a7", size = 28345, upload-time = "2023-12-18T21:35:23.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/9b/c21a9c1d5ea4847989f1eb00e3147e38e79aaea7c4b4d1cbd4f1afae9740/multiformats_config-0.3.1-py3-none-any.whl", hash = "sha256:dec4c9d42ed0d9305889b67440f72e8e8d74b82b80abd7219667764b5b0a8e1d", size = 17153, upload-time = "2023-12-18T21:35:21.171Z" }, +] + [[package]] name = "packaging" version = "26.2" @@ -834,6 +871,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "typing-validation" +version = "1.2.12" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.14'", +] +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/c0/374639373a99b62f51c3204521235906dca1ed1886e73f65d6664465b187/typing_validation-1.2.12.tar.gz", hash = "sha256:7ea9463a18bd04922e799cac1954f687e68e9564773f81db491536852ffe1d54", size = 774523, upload-time = "2025-03-18T14:54:49.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/7b/29a088c5be56f40e0b1e611c460681f411ce79f0083d2cd3b233a35b7c4d/typing_validation-1.2.12-py3-none-any.whl", hash = "sha256:d68e22a41bf2b98ae91e5d6407db56e9ef83e9e5600164a7aff64aaa082fc232", size = 20657, upload-time = "2025-03-18T14:54:47.529Z" }, +] + +[[package]] +name = "typing-validation" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", +] +sdist = { url = "https://files.pythonhosted.org/packages/60/0c/1a8a4850f9b24fb8c8ac2f99394d3fb4fd929360ebfd1436e13ecebf5baf/typing_validation-2.2.1.tar.gz", hash = "sha256:db33e27b0269098902677aa23eba07fcd13f076b6ef2ffc1bb341be5c0942c75", size = 61583, upload-time = "2026-07-18T13:25:01.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/0c/f44ed22596e22ec9b359d4bb99742ee3380aa433e9e161ff7acb824e5fac/typing_validation-2.2.1-py3-none-any.whl", hash = "sha256:7b6740a98ebd3ba73a542032406807cd4a9620d578b69040a99bd58bdde9da7c", size = 71132, upload-time = "2026-07-18T13:25:00.275Z" }, +] + [[package]] name = "uvicorn" version = "0.51.0" From 64fc5578fb7ca87bb1709ea888333f0942e08439 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Thu, 6 Aug 2026 10:15:43 +0200 Subject: [PATCH 20/22] refactor(vc): switch out httpx for httpx2 --- dva-vc-manager/pyproject.toml | 2 +- dva-vc-manager/uv.lock | 45 ++++++++++++++++++----------------- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/dva-vc-manager/pyproject.toml b/dva-vc-manager/pyproject.toml index 26065637..606302e2 100644 --- a/dva-vc-manager/pyproject.toml +++ b/dva-vc-manager/pyproject.toml @@ -32,9 +32,9 @@ packages = ["src/dva_vc_manager"] [dependency-groups] dev = [ "pytest>=8.3.5", - "httpx>=0.27.0", "pytest-asyncio>=0.24.0", "ruff>=0.14.0", + "httpx2>=2.9.1", ] [tool.pytest.ini_options] diff --git a/dva-vc-manager/uv.lock b/dva-vc-manager/uv.lock index 7cdfd2bd..ce70e186 100644 --- a/dva-vc-manager/uv.lock +++ b/dva-vc-manager/uv.lock @@ -129,15 +129,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/15/7bcf28a3f971e1b0523fab46ae3ca935a589249544187558e5a8e70af393/bases-0.3.0-py3-none-any.whl", hash = "sha256:a2fef3366f3e522ff473d2e95c21523fe8e44251038d5c6150c01481585ebf5b", size = 36053, upload-time = "2023-12-18T16:57:14.253Z" }, ] -[[package]] -name = "certifi" -version = "2026.6.17" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, -] - [[package]] name = "cffi" version = "2.1.0" @@ -287,7 +278,7 @@ dependencies = [ [package.dev-dependencies] dev = [ - { name = "httpx" }, + { name = "httpx2" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -308,7 +299,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ - { name = "httpx", specifier = ">=0.27.0" }, + { name = "httpx2", specifier = ">=2.9.1" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-asyncio", specifier = ">=0.24.0" }, { name = "ruff", specifier = ">=0.14.0" }, @@ -352,31 +343,32 @@ wheels = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, { name = "h11" }, + { name = "truststore" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, ] [[package]] -name = "httpx" -version = "0.28.1" +name = "httpx2" +version = "2.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "httpcore2" }, { name = "idna" }, + { name = "truststore" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, ] [[package]] @@ -850,6 +842,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" From 8a89cf439fa9b492561afa018ef5e495028430d0 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Thu, 6 Aug 2026 10:32:58 +0200 Subject: [PATCH 21/22] refactor(vc): persist only the ed25519 seed --- dva-vc-manager/src/dva_vc_manager/keys.py | 104 +++++++------------- dva-vc-manager/src/dva_vc_manager/routes.py | 4 +- dva-vc-manager/tests/test_keys.py | 41 ++++++-- 3 files changed, 72 insertions(+), 77 deletions(-) diff --git a/dva-vc-manager/src/dva_vc_manager/keys.py b/dva-vc-manager/src/dva_vc_manager/keys.py index 01327b96..b68281ac 100644 --- a/dva-vc-manager/src/dva_vc_manager/keys.py +++ b/dva-vc-manager/src/dva_vc_manager/keys.py @@ -1,10 +1,9 @@ """ Ed25519 signing-key store. -Persistence format: ``base64(private key seed)|base64(public key)`` – -PyNaCl exposes the private key as a 32-byte seed, the public key as 32 -bytes. The seed is the canonical "private key" representation for -Ed25519 in libsodium. +Persistence format: base64 of the 32-byte private seed, encoded with +PyNaCl's own codec. The public key is *derived* from the seed, so a +``SigningKey`` is the whole keypair and only the seed is written to disk. POSIX file permissions 0600 are applied to the key file, and 0700 to parent directories. @@ -12,90 +11,63 @@ from __future__ import annotations -import base64 import os from pathlib import Path -from nacl.signing import SigningKey, VerifyKey +from nacl.encoding import Base64Encoder +from nacl.signing import SigningKey from .did_key import public_key_to_did_key -__all__ = ["SigningKeyStore", "KeyPair"] - - -class KeyPair: - """A loaded Ed25519 keypair (PyNaCl types).""" - - def __init__(self, private: SigningKey, public: VerifyKey) -> None: - self._private = private - self._public = public - - @property - def private(self) -> SigningKey: - return self._private - - @property - def public(self) -> VerifyKey: - return self._public +__all__ = ["SigningKeyStore"] class SigningKeyStore: - """Persistent Ed25519 keypair store backed by a filesystem path.""" + """Persistent Ed25519 signing key backed by a filesystem path.""" def __init__(self, path: str) -> None: self._path = Path(path) - self._cached: KeyPair | None = None + self._cached: SigningKey | None = None @property def path(self) -> Path: """The key file backing this store.""" return self._path - def load_or_generate(self) -> KeyPair: - """Return the cached keypair, load from disk, or generate+persist.""" - if self._cached is not None: - return self._cached - - if self._path.exists(): - content = self._path.read_text() - parts = content.split("|") - if len(parts) != 2: - raise RuntimeError( - f"Signing key file at {self._path} is malformed (expected " - f"'base64(priv)|base64(pub)', got {len(parts)} segment(s)). " - "Remove the file manually if you intend to generate a new key." - ) - priv_seed = base64.b64decode(parts[0]) - pub_bytes = base64.b64decode(parts[1]) - signing_key = SigningKey(priv_seed) - if bytes(signing_key.verify_key) != pub_bytes: - raise RuntimeError( - f"Signing key file at {self._path} is inconsistent: " - "public key does not match private seed. Refusing to load." - ) - self._cached = KeyPair(signing_key, signing_key.verify_key) - return self._cached - - # File doesn't exist — generate a fresh keypair and persist it. + def load_or_generate(self) -> SigningKey: + """Return the cached key, load it from disk, or generate and persist one.""" + if self._cached is None: + self._cached = ( + self._load() if self._path.exists() else self._generate_and_persist() + ) + return self._cached + + def _load(self) -> SigningKey: + try: + return SigningKey(self._path.read_bytes().strip(), encoder=Base64Encoder) + except (ValueError, TypeError) as e: + raise RuntimeError( + f"Signing key file at {self._path} is malformed ({e}). Expected " + "base64 of the 32-byte Ed25519 seed. Delete the file to generate " + "a fresh key -- note that this changes the issuer did:key, which " + "must then be re-registered with every verifying participant." + ) from e + + def _generate_and_persist(self) -> SigningKey: signing_key = SigningKey.generate() - self._cached = KeyPair(signing_key, signing_key.verify_key) + # A directory we create is ours, so lock it down; one the operator + # already provided is left as we found it. parent = self._path.parent or Path(".") - parent.mkdir(parents=True, exist_ok=True) - try: - os.chmod(parent, 0o700) - except (NotImplementedError, OSError): - pass # Non-POSIX filesystems (Windows) + parent.mkdir(mode=0o700, parents=True, exist_ok=True) - priv_b64 = base64.b64encode(bytes(signing_key)).decode("ascii") - pub_b64 = base64.b64encode(bytes(signing_key.verify_key)).decode("ascii") - self._path.write_text(f"{priv_b64}|{pub_b64}") + # Open with 0600 up front rather than chmod-ing afterwards, so the + # seed is never briefly readable by other users. + fd = os.open(self._path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, "wb") as fh: + fh.write(signing_key.encode(encoder=Base64Encoder)) - try: - os.chmod(self._path, 0o600) - except (NotImplementedError, OSError): - pass - return self._cached + return signing_key def issuer_did_key(self) -> str: """Return the ``did:key`` identifier of the loaded public key.""" @@ -103,4 +75,4 @@ def issuer_did_key(self) -> str: raise RuntimeError( "SigningKeyStore.load_or_generate() must be called before issuer_did_key()" ) - return public_key_to_did_key(self._cached.public) + return public_key_to_did_key(self._cached.verify_key) diff --git a/dva-vc-manager/src/dva_vc_manager/routes.py b/dva-vc-manager/src/dva_vc_manager/routes.py index 4907d593..645ba12c 100644 --- a/dva-vc-manager/src/dva_vc_manager/routes.py +++ b/dva-vc-manager/src/dva_vc_manager/routes.py @@ -61,7 +61,7 @@ async def aov_issue( key_store: SigningKeyStore = Depends(get_key_store), ) -> AovIssueResponse: """Issue an AoV JWS credential from the veracity-check results.""" - keypair = key_store.load_or_generate() + signing_key = key_store.load_or_generate() issuer_did_key = key_store.issuer_did_key() # build_aov_payload embeds these values into the VC JSON-LD payload. @@ -77,7 +77,7 @@ async def aov_issue( data_exchange_id=req.data_exchange_id, payload=req.payload, ) - jws = sign_jws(claims, keypair.private, issuer_did_key) + jws = sign_jws(claims, signing_key, issuer_did_key) return AovIssueResponse(jws=jws) diff --git a/dva-vc-manager/tests/test_keys.py b/dva-vc-manager/tests/test_keys.py index 6d84e687..2dc7198e 100644 --- a/dva-vc-manager/tests/test_keys.py +++ b/dva-vc-manager/tests/test_keys.py @@ -2,9 +2,12 @@ from __future__ import annotations +import base64 import os from pathlib import Path +import pytest + from dva_vc_manager.keys import SigningKeyStore @@ -12,31 +15,51 @@ def test_generates_key_on_first_run_when_file_missing(tmp_path: Path) -> None: key_path = tmp_path / "subdir" / "key.pem" store = SigningKeyStore(str(key_path)) - pair = store.load_or_generate() + signing_key = store.load_or_generate() assert key_path.exists(), "Key file must be created on first run" - assert pair.private is not None - assert pair.public is not None - # Permissions: 0600 on POSIX + assert len(bytes(signing_key)) == 32 + assert signing_key.verify_key is not None + # Permissions: 0600 on the key, 0700 on a directory we created. if os.name == "posix": assert (key_path.stat().st_mode & 0o777) == 0o600 + assert (key_path.parent.stat().st_mode & 0o777) == 0o700 def test_persists_and_reloads_the_same_key_across_instances(tmp_path: Path) -> None: key_path = tmp_path / "key.pem" store1 = SigningKeyStore(str(key_path)) - pair1 = store1.load_or_generate() - pub1_bytes = bytes(pair1.public) + key1 = store1.load_or_generate() # New instance pointing at the same file — must load, not regenerate. store2 = SigningKeyStore(str(key_path)) - pair2 = store2.load_or_generate() - pub2_bytes = bytes(pair2.public) + key2 = store2.load_or_generate() - assert pub1_bytes == pub2_bytes, ( + assert bytes(key1.verify_key) == bytes(key2.verify_key), ( "reload must yield the same public key as the original generation" ) + assert bytes(key1) == bytes(key2), "reload must yield the same private seed" + + +def test_stores_only_the_seed_not_the_public_key(tmp_path: Path) -> None: + """The public key is derived from the seed, so it is not persisted.""" + key_path = tmp_path / "key.pem" + signing_key = SigningKeyStore(str(key_path)).load_or_generate() + + contents = key_path.read_bytes() + assert b"|" not in contents, "legacy seed|public separator must be gone" + assert base64.b64decode(contents) == bytes(signing_key) + assert len(base64.b64decode(contents)) == 32 + + +def test_malformed_key_file_raises_rather_than_regenerating(tmp_path: Path) -> None: + """A corrupt key file must fail loudly, never mint a new issuer did:key.""" + key_path = tmp_path / "key.pem" + key_path.write_text("not base64 at all!") + + with pytest.raises(RuntimeError, match="malformed"): + SigningKeyStore(str(key_path)).load_or_generate() def test_derived_did_key_starts_with_z6mk(tmp_path: Path) -> None: From 498b5ab933fc80eb3ad0595208b7911e06c76d32 Mon Sep 17 00:00:00 2001 From: bzp99 Date: Thu, 6 Aug 2026 11:08:27 +0200 Subject: [PATCH 22/22] refactor(vc)!: replace hand-rolled JWS with joserfc --- dva-vc-manager/README.md | 2 +- dva-vc-manager/pyproject.toml | 2 +- dva-vc-manager/src/dva_vc_manager/did_key.py | 14 +-- dva-vc-manager/src/dva_vc_manager/keys.py | 23 ++-- dva-vc-manager/src/dva_vc_manager/main.py | 2 +- dva-vc-manager/src/dva_vc_manager/signing.py | 101 +++++++++-------- dva-vc-manager/tests/test_did_key.py | 16 +-- dva-vc-manager/tests/test_jws.py | 52 +++++++-- dva-vc-manager/tests/test_keys.py | 14 +-- dva-vc-manager/uv.lock | 108 ++++++++++++------- 10 files changed, 209 insertions(+), 125 deletions(-) diff --git a/dva-vc-manager/README.md b/dva-vc-manager/README.md index 5f5a2ec2..567dbda1 100644 --- a/dva-vc-manager/README.md +++ b/dva-vc-manager/README.md @@ -3,7 +3,7 @@ DVA VC Manager is a FastAPI service hosted at each **Participant** that owns the Attestation-of-Veracity credential lifecycle: -* **Issues** AoV credentials as W3C VC 2.0 JSON-LD JWS (Ed25519/EdDSA) at the provider side. +* **Issues** AoV credentials as W3C VC 2.0 JSON-LD JWS (Ed25519) at the provider side. * **Verifies** AoV JWS credentials at the consumer side against a local ``did:key`` whitelist. ## Why diff --git a/dva-vc-manager/pyproject.toml b/dva-vc-manager/pyproject.toml index 606302e2..ca4e18d4 100644 --- a/dva-vc-manager/pyproject.toml +++ b/dva-vc-manager/pyproject.toml @@ -9,10 +9,10 @@ requires-python = ">=3.10" dependencies = [ "asyncpg>=0.30.0", "fastapi~=0.136.3", + "joserfc>=1.7.4", "multiformats>=0.3.1", "pydantic>=2.10.6", "pydantic-settings>=2.5.0", - "pynacl>=1.5.0", "pyyaml>=6.0", "structlog>=25.1.0", "uvicorn>=0.34.3", diff --git a/dva-vc-manager/src/dva_vc_manager/did_key.py b/dva-vc-manager/src/dva_vc_manager/did_key.py index 88ee2672..8c33f9c1 100644 --- a/dva-vc-manager/src/dva_vc_manager/did_key.py +++ b/dva-vc-manager/src/dva_vc_manager/did_key.py @@ -11,8 +11,8 @@ from __future__ import annotations +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey from multiformats import multibase, multicodec -from nacl.signing import VerifyKey ED25519_RAW_SIZE = 32 ED25519_MULTICODEC = "ed25519-pub" @@ -20,9 +20,9 @@ DID_KEY_SCHEME = "did:key:" -def public_key_to_did_key(public_key: VerifyKey) -> str: - """Encode a PyNaCl Ed25519 VerifyKey into a ``did:key`` identifier.""" - raw = bytes(public_key) +def public_key_to_did_key(public_key: Ed25519PublicKey) -> str: + """Encode an Ed25519 public key into a ``did:key`` identifier.""" + raw = public_key.public_bytes_raw() # multicodec.wrap does not check the payload length for us. if len(raw) != ED25519_RAW_SIZE: raise ValueError(f"Ed25519 public key must be exactly 32 bytes, got {len(raw)}") @@ -30,8 +30,8 @@ def public_key_to_did_key(public_key: VerifyKey) -> str: return DID_KEY_SCHEME + multibase.encode(wrapped, MULTIBASE_BASE58BTC) -def did_key_to_public_key(did_key: str) -> VerifyKey: - """Decode a ``did:key`` Ed25519 identifier back into a PyNaCl VerifyKey.""" +def did_key_to_public_key(did_key: str) -> Ed25519PublicKey: + """Decode a ``did:key`` Ed25519 identifier back into an Ed25519 public key.""" if not did_key.startswith(DID_KEY_SCHEME): raise ValueError(f"not a did:key identifier: {did_key}") @@ -49,4 +49,4 @@ def did_key_to_public_key(did_key: str) -> VerifyKey: if codec.name != ED25519_MULTICODEC: raise ValueError(f"did:key must be {ED25519_MULTICODEC}, got {codec.name}") - return VerifyKey(raw) + return Ed25519PublicKey.from_public_bytes(raw) diff --git a/dva-vc-manager/src/dva_vc_manager/keys.py b/dva-vc-manager/src/dva_vc_manager/keys.py index b68281ac..8f74aa6a 100644 --- a/dva-vc-manager/src/dva_vc_manager/keys.py +++ b/dva-vc-manager/src/dva_vc_manager/keys.py @@ -11,11 +11,11 @@ from __future__ import annotations +import base64 import os from pathlib import Path -from nacl.encoding import Base64Encoder -from nacl.signing import SigningKey +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from .did_key import public_key_to_did_key @@ -27,14 +27,14 @@ class SigningKeyStore: def __init__(self, path: str) -> None: self._path = Path(path) - self._cached: SigningKey | None = None + self._cached: Ed25519PrivateKey | None = None @property def path(self) -> Path: """The key file backing this store.""" return self._path - def load_or_generate(self) -> SigningKey: + def load_or_generate(self) -> Ed25519PrivateKey: """Return the cached key, load it from disk, or generate and persist one.""" if self._cached is None: self._cached = ( @@ -42,9 +42,12 @@ def load_or_generate(self) -> SigningKey: ) return self._cached - def _load(self) -> SigningKey: + def _load(self) -> Ed25519PrivateKey: try: - return SigningKey(self._path.read_bytes().strip(), encoder=Base64Encoder) + # validate=True so a stray character is an error rather than being + # silently discarded, which is base64's default behaviour. + seed = base64.b64decode(self._path.read_bytes().strip(), validate=True) + return Ed25519PrivateKey.from_private_bytes(seed) except (ValueError, TypeError) as e: raise RuntimeError( f"Signing key file at {self._path} is malformed ({e}). Expected " @@ -53,8 +56,8 @@ def _load(self) -> SigningKey: "must then be re-registered with every verifying participant." ) from e - def _generate_and_persist(self) -> SigningKey: - signing_key = SigningKey.generate() + def _generate_and_persist(self) -> Ed25519PrivateKey: + signing_key = Ed25519PrivateKey.generate() # A directory we create is ours, so lock it down; one the operator # already provided is left as we found it. @@ -65,7 +68,7 @@ def _generate_and_persist(self) -> SigningKey: # seed is never briefly readable by other users. fd = os.open(self._path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "wb") as fh: - fh.write(signing_key.encode(encoder=Base64Encoder)) + fh.write(base64.b64encode(signing_key.private_bytes_raw())) return signing_key @@ -75,4 +78,4 @@ def issuer_did_key(self) -> str: raise RuntimeError( "SigningKeyStore.load_or_generate() must be called before issuer_did_key()" ) - return public_key_to_did_key(self._cached.verify_key) + return public_key_to_did_key(self._cached.public_key()) diff --git a/dva-vc-manager/src/dva_vc_manager/main.py b/dva-vc-manager/src/dva_vc_manager/main.py index cc7e434a..f795d388 100644 --- a/dva-vc-manager/src/dva_vc_manager/main.py +++ b/dva-vc-manager/src/dva_vc_manager/main.py @@ -55,7 +55,7 @@ def create_app() -> FastAPI: title="DVA VC Manager", description=( "Issues and verifies Attestation of Veracity (AoV) credentials as " - "W3C VC 2.0 JSON-LD JWS (Ed25519) using PyNaCl. Hosted at " + "W3C VC 2.0 JSON-LD JWS (Ed25519). Hosted at " "each Participant. Called by the DVA API during credential " "issuance in the synchronous attestation flow." ), diff --git a/dva-vc-manager/src/dva_vc_manager/signing.py b/dva-vc-manager/src/dva_vc_manager/signing.py index 80a7bb7d..eff31caf 100644 --- a/dva-vc-manager/src/dva_vc_manager/signing.py +++ b/dva-vc-manager/src/dva_vc_manager/signing.py @@ -1,27 +1,39 @@ """ JWS issuance and verification. -Facilitates the production of a compact JWS (``header.payload.signature``) -over a W3C VC 2.0 JSON-LD payload. +Produces a compact JWS (``header.payload.signature``) over a W3C VC 2.0 +JSON-LD payload. The JOSE layer is handled by ``joserfc``; this module +owns the AoV payload shape. """ from __future__ import annotations -import base64 import json from typing import Any -from nacl.exceptions import BadSignatureError -from nacl.signing import SigningKey, VerifyKey +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) +from joserfc import jws +from joserfc.errors import BadSignatureError, JoseError +from joserfc.jwk import OKPKey from pydantic import BaseModel -# JWS header constants -JWS_HEADER_ALG = "EdDSA" +# JWS header constants. RFC 9864 deprecates the polymorphic "EdDSA" +# identifier in favour of the fully specified "Ed25519". +JWS_HEADER_ALG = "Ed25519" JWS_HEADER_TYPE = "VC+LD-JSON+JWS" -VC_CONTEXT = "https://www.w3.org/2018/credentials/v1" +VC_CONTEXT = "https://www.w3.org/ns/credentials/v2" VC_TYPE = "VerifiableCredential" AOV_TYPE = "AttestationOfVeracity" +# joserfc's default registry admits only its "recommended" algorithms, which +# includes neither Ed25519 nor EdDSA, so the registry must be named explicitly. +# That doubles as an allowlist: a JWS declaring any other alg -- including the +# deprecated "EdDSA" -- is rejected before its signature is ever checked. +_REGISTRY = jws.JWSRegistry(algorithms=[JWS_HEADER_ALG]) + class AovClaims(BaseModel): """The eight AoV credentialSubject claims.""" @@ -42,25 +54,14 @@ class MalformedJws(ValueError): """The string is not a well-formed compact JWS.""" -def split_jws(jws: str) -> tuple[str, str, str]: +def split_jws(jws_str: str) -> tuple[str, str, str]: """Split a compact JWS into its header, payload and signature segments.""" - parts = jws.split(".") + parts = jws_str.split(".") if len(parts) != 3: raise MalformedJws("Compact JWS must have 3 dot-separated parts") return parts[0], parts[1], parts[2] -def _b64url(data: bytes) -> str: - """Standard JWS base64url **without** padding (per RFC 7515 §2.2.2).""" - return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") - - -def _b64url_decode(segment: str) -> bytes: - """Inverse of :func:`_b64url` – re-adds padding before decoding.""" - pad = (-len(segment)) % 4 - return base64.urlsafe_b64decode(segment + "=" * pad) - - def _json_compact(obj: dict[str, Any]) -> bytes: """Compact JSON encoding.""" return json.dumps(obj, separators=(",", ":"), ensure_ascii=False).encode("utf-8") @@ -86,39 +87,45 @@ def build_aov_payload(claims: AovClaims, issuer_did_key: str) -> dict[str, Any]: } -def _jws_header() -> dict[str, str]: - return {"alg": JWS_HEADER_ALG, "typ": JWS_HEADER_TYPE} +def sign_jws( + claims: AovClaims, signing_key: Ed25519PrivateKey, issuer_did_key: str +) -> str: + """Sign and produce a compact JWS string.""" + header = {"alg": JWS_HEADER_ALG, "typ": JWS_HEADER_TYPE} + payload = _json_compact(build_aov_payload(claims, issuer_did_key)) + return jws.serialize_compact( + header, payload, OKPKey.import_key(signing_key), registry=_REGISTRY + ) -def sign_jws(claims: AovClaims, signing_key: SigningKey, issuer_did_key: str) -> str: - """Sign and produce a compact JWS string.""" - header_b64 = _b64url(_json_compact(_jws_header())) - payload_b64 = _b64url(_json_compact(build_aov_payload(claims, issuer_did_key))) - signing_input = f"{header_b64}.{payload_b64}".encode("ascii") - - # PyNaCl SigningKey.sign returns a SignedMessage; .signature is the - # detached raw 64-byte EdDSA signature. - signature = signing_key.sign(signing_input).signature - signature_b64 = _b64url(signature) - return f"{header_b64}.{payload_b64}.{signature_b64}" - - -def verify_jws(jws: str, public_key: VerifyKey) -> bool: - """Verify a compact JWS.""" - header_b64, payload_b64, signature_b64 = split_jws(jws) - signing_input = f"{header_b64}.{payload_b64}".encode("ascii") - signature = _b64url_decode(signature_b64) +def verify_jws(jws_str: str, public_key: Ed25519PublicKey) -> bool: + """ + Verify a compact JWS. + + Returns False when the signature does not check out; raises + :class:`MalformedJws` when the input is not a usable JWS at all. + """ try: - public_key.verify(signing_input, signature) - return True + jws.deserialize_compact( + jws_str, OKPKey.import_key(public_key), registry=_REGISTRY + ) except BadSignatureError: return False + except JoseError as e: + # joserfc raises JoseError subclasses, which are not ValueErrors; + # callers here treat malformed input as ValueError. + raise MalformedJws(str(e)) from e + return True -def decode_payload(jws: str) -> dict[str, Any]: - """Decode (without verifying) the payload middle segment of a JWS.""" - _, payload_b64, _ = split_jws(jws) - payload = json.loads(_b64url_decode(payload_b64)) +def decode_payload(jws_str: str) -> dict[str, Any]: + """Decode (without verifying) the payload segment of a JWS.""" + try: + extracted = jws.extract_compact(jws_str.encode("ascii")) + except (JoseError, UnicodeEncodeError) as e: + raise MalformedJws(str(e)) from e + + payload = json.loads(extracted.payload) if not isinstance(payload, dict): raise MalformedJws( f"JWS payload must be a JSON object, got {type(payload).__name__}" diff --git a/dva-vc-manager/tests/test_did_key.py b/dva-vc-manager/tests/test_did_key.py index a66d796c..6096c0c8 100644 --- a/dva-vc-manager/tests/test_did_key.py +++ b/dva-vc-manager/tests/test_did_key.py @@ -3,8 +3,8 @@ from __future__ import annotations import pytest +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from multiformats import multibase, multicodec -from nacl.signing import SigningKey from dva_vc_manager.did_key import ( did_key_to_public_key, @@ -13,8 +13,8 @@ def test_ed25519_key_round_trips_through_did_key() -> None: - signing_key = SigningKey.generate() - pub = signing_key.verify_key + signing_key = Ed25519PrivateKey.generate() + pub = signing_key.public_key() did_key = public_key_to_did_key(pub) round_tripped_pub = did_key_to_public_key(did_key) @@ -33,8 +33,8 @@ def test_known_spec_vector() -> None: def test_starts_with_did_key_z6mk() -> None: - signing_key = SigningKey.generate() - pub = signing_key.verify_key + signing_key = Ed25519PrivateKey.generate() + pub = signing_key.public_key() did_key = public_key_to_did_key(pub) assert did_key.startswith("did:key:z6Mk"), ( "did:key identifier must start with 'did:key:z6Mk'" @@ -42,7 +42,11 @@ def test_starts_with_did_key_z6mk() -> None: def _did_key(codec: str, base: str = "base58btc", raw: bytes | None = None) -> str: - raw = raw if raw is not None else bytes(SigningKey.generate().verify_key) + raw = ( + raw + if raw is not None + else Ed25519PrivateKey.generate().public_key().public_bytes_raw() + ) return "did:key:" + multibase.encode(multicodec.wrap(codec, raw), base) diff --git a/dva-vc-manager/tests/test_jws.py b/dva-vc-manager/tests/test_jws.py index b09f197e..8af20290 100644 --- a/dva-vc-manager/tests/test_jws.py +++ b/dva-vc-manager/tests/test_jws.py @@ -2,8 +2,11 @@ from __future__ import annotations +import base64 +import json + import pytest -from nacl.signing import SigningKey, VerifyKey +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from dva_vc_manager.signing import ( AovClaims, @@ -30,9 +33,13 @@ def _sample_claims() -> AovClaims: _KNOWN_DID_KEY = "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" +def _b64u(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + def test_signs_and_verifies_a_valid_aov() -> None: - signing_key = SigningKey.generate() - public_key = VerifyKey(bytes(signing_key.verify_key)) + signing_key = Ed25519PrivateKey.generate() + public_key = signing_key.public_key() jws = sign_jws(_sample_claims(), signing_key, _KNOWN_DID_KEY) @@ -44,8 +51,8 @@ def test_signs_and_verifies_a_valid_aov() -> None: def test_tampered_payload_fails_verification() -> None: - signing_key = SigningKey.generate() - public_key = VerifyKey(bytes(signing_key.verify_key)) + signing_key = Ed25519PrivateKey.generate() + public_key = signing_key.public_key() jws = sign_jws(_sample_claims(), signing_key, _KNOWN_DID_KEY) parts = jws.split(".") @@ -60,19 +67,46 @@ def test_tampered_payload_fails_verification() -> None: def test_rejection_of_a_clearly_malformed_jws() -> None: - signing_key = SigningKey.generate() - public_key = VerifyKey(bytes(signing_key.verify_key)) + signing_key = Ed25519PrivateKey.generate() + public_key = signing_key.public_key() with pytest.raises(MalformedJws): verify_jws("not.a.jws.at.all", public_key) +def test_header_declares_the_rfc9864_algorithm() -> None: + signing_key = Ed25519PrivateKey.generate() + jws = sign_jws(_sample_claims(), signing_key, _KNOWN_DID_KEY) + + header = json.loads(base64.urlsafe_b64decode(jws.split(".")[0] + "==")) + + assert header == {"alg": "Ed25519", "typ": "VC+LD-JSON+JWS"} + + +@pytest.mark.parametrize("alg", ["none", "HS256", "RS256", "EdDSA"]) +def test_only_the_ed25519_algorithm_is_accepted(alg: str) -> None: + """ + The registry is an allowlist, checked before any signature verification. + + ``EdDSA`` is included deliberately: RFC 9864 deprecates it, and this + service does not accept it, so credentials issued before the switch no + longer verify. + """ + signing_key = Ed25519PrivateKey.generate() + header = _b64u(json.dumps({"alg": alg, "typ": "VC+LD-JSON+JWS"}).encode()) + payload = _b64u(json.dumps({"issuer": _KNOWN_DID_KEY}).encode()) + signature = _b64u(signing_key.sign(f"{header}.{payload}".encode("ascii"))) + + with pytest.raises(MalformedJws): + verify_jws(f"{header}.{payload}.{signature}", signing_key.public_key()) + + def test_payload_shape_includes_context_type_issuer_validfrom_subject() -> None: """Validate the W3C VC JSON-LD structure.""" - signing_key = SigningKey.generate() + signing_key = Ed25519PrivateKey.generate() jws = sign_jws(_sample_claims(), signing_key, _KNOWN_DID_KEY) payload = decode_payload(jws) - assert payload["@context"] == ["https://www.w3.org/2018/credentials/v1"] + assert payload["@context"] == ["https://www.w3.org/ns/credentials/v2"] assert payload["type"] == ["VerifiableCredential", "AttestationOfVeracity"] assert payload["issuer"] == _KNOWN_DID_KEY assert payload["validFrom"] == "2024-01-01T00:00:00Z" diff --git a/dva-vc-manager/tests/test_keys.py b/dva-vc-manager/tests/test_keys.py index 2dc7198e..b70af42d 100644 --- a/dva-vc-manager/tests/test_keys.py +++ b/dva-vc-manager/tests/test_keys.py @@ -18,8 +18,8 @@ def test_generates_key_on_first_run_when_file_missing(tmp_path: Path) -> None: signing_key = store.load_or_generate() assert key_path.exists(), "Key file must be created on first run" - assert len(bytes(signing_key)) == 32 - assert signing_key.verify_key is not None + assert len(signing_key.private_bytes_raw()) == 32 + assert signing_key.public_key() is not None # Permissions: 0600 on the key, 0700 on a directory we created. if os.name == "posix": assert (key_path.stat().st_mode & 0o777) == 0o600 @@ -36,10 +36,12 @@ def test_persists_and_reloads_the_same_key_across_instances(tmp_path: Path) -> N store2 = SigningKeyStore(str(key_path)) key2 = store2.load_or_generate() - assert bytes(key1.verify_key) == bytes(key2.verify_key), ( - "reload must yield the same public key as the original generation" + assert ( + key1.public_key().public_bytes_raw() == key2.public_key().public_bytes_raw() + ), "reload must yield the same public key as the original generation" + assert key1.private_bytes_raw() == key2.private_bytes_raw(), ( + "reload must yield the same private seed" ) - assert bytes(key1) == bytes(key2), "reload must yield the same private seed" def test_stores_only_the_seed_not_the_public_key(tmp_path: Path) -> None: @@ -49,7 +51,7 @@ def test_stores_only_the_seed_not_the_public_key(tmp_path: Path) -> None: contents = key_path.read_bytes() assert b"|" not in contents, "legacy seed|public separator must be gone" - assert base64.b64decode(contents) == bytes(signing_key) + assert base64.b64decode(contents) == signing_key.private_bytes_raw() assert len(base64.b64decode(contents)) == 32 diff --git a/dva-vc-manager/uv.lock b/dva-vc-manager/uv.lock index ce70e186..687e78d8 100644 --- a/dva-vc-manager/uv.lock +++ b/dva-vc-manager/uv.lock @@ -260,6 +260,63 @@ 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.0" +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/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + [[package]] name = "dva-vc-manager" version = "0.1.0" @@ -267,10 +324,10 @@ source = { editable = "." } dependencies = [ { name = "asyncpg" }, { name = "fastapi" }, + { name = "joserfc" }, { name = "multiformats" }, { name = "pydantic" }, { name = "pydantic-settings" }, - { name = "pynacl" }, { name = "pyyaml" }, { name = "structlog" }, { name = "uvicorn" }, @@ -288,10 +345,10 @@ dev = [ requires-dist = [ { name = "asyncpg", specifier = ">=0.30.0" }, { name = "fastapi", specifier = "~=0.136.3" }, + { name = "joserfc", specifier = ">=1.7.4" }, { name = "multiformats", specifier = ">=0.3.1" }, { name = "pydantic", specifier = ">=2.10.6" }, { name = "pydantic-settings", specifier = ">=2.5.0" }, - { name = "pynacl", specifier = ">=1.5.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "structlog", specifier = ">=25.1.0" }, { name = "uvicorn", specifier = ">=0.34.3" }, @@ -389,6 +446,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "joserfc" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/e0/27a6a081ae25420eda6768ceae05d7022a7f2447f420588843f2a44e4298/joserfc-1.7.4.tar.gz", hash = "sha256:b3bc561672ae541b17a9237053b48a03dacddd92d68047b3ecdfb4b5714a88ed", size = 234027, upload-time = "2026-07-19T15:43:02.739Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/bf/249dcd99b3376375910b7fa922383b57792975c8758f50d44612e749226c/joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463", size = 71000, upload-time = "2026-07-19T15:43:01.299Z" }, +] + [[package]] name = "multiformats" version = "0.3.1.post4" @@ -598,41 +667,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] -[[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 = "pytest" version = "9.1.1"