diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 67bcc0a..9896c7a 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -84,7 +84,7 @@ By participating in this project, you agree to abide by our Code of Conduct. - Run mypy to check static typing: ```bash .venv/bin/python -m mypy \ - crypto_config.py crypto_core.py pqc_agent_tools.py pqc_app.py \ + api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py ui_helpers.py \ tests/test_agent_tools.py tests/test_crypto_core.py ``` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5eb54a4..83539df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,13 +32,13 @@ jobs: run: python -m pip install -r requirements-dev.txt - name: Check formatting - run: python -m black --check api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py pqc_app.py ui_helpers.py setup.py tests/test_agent_tools.py tests/test_api_app.py tests/test_crypto_core.py tests/test_ui_helpers.py + run: python -m black --check api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py ui_helpers.py setup.py tests/test_agent_tools.py tests/test_api_app.py tests/test_crypto_core.py tests/test_ui_helpers.py - name: Lint - run: python -m flake8 api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py pqc_app.py ui_helpers.py setup.py tests/test_agent_tools.py tests/test_api_app.py tests/test_crypto_core.py tests/test_ui_helpers.py + run: python -m flake8 api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py ui_helpers.py setup.py tests/test_agent_tools.py tests/test_api_app.py tests/test_crypto_core.py tests/test_ui_helpers.py - name: Type check - run: python -m mypy api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py pqc_app.py ui_helpers.py tests/test_agent_tools.py tests/test_api_app.py tests/test_crypto_core.py tests/test_ui_helpers.py + run: python -m mypy api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py ui_helpers.py tests/test_agent_tools.py tests/test_api_app.py tests/test_crypto_core.py tests/test_ui_helpers.py - name: Unit tests without native liboqs run: > @@ -93,15 +93,21 @@ jobs: /tmp/qe-wheel-test/bin/quantum-encryptor-agent health --json /tmp/qe-wheel-test/bin/python -I - <<'PY' from pathlib import Path + from importlib.metadata import requires + from importlib.util import find_spec import sys import api_app import pqc_agent_tools + import ui_helpers prefix = Path(sys.prefix).resolve() - for module in (api_app, pqc_agent_tools): + for module in (api_app, pqc_agent_tools, ui_helpers): module_path = Path(module.__file__).resolve() assert module_path.is_relative_to(prefix), (module.__name__, module_path, prefix) + assert find_spec("pqc_app") is None + package_requirements = requires("quantum-encryptor") or () + assert all(not requirement.lower().startswith("streamlit") for requirement in package_requirements) PY - name: Smoke installed web UI @@ -155,7 +161,7 @@ jobs: run: npm audit --package-lock-only --audit-level=high - name: Run Python security lint - run: python -m bandit -q -r api_app.py crypto_core.py pqc_agent_tools.py pqc_app.py ui_helpers.py + run: python -m bandit -q -r api_app.py crypto_core.py pqc_agent_tools.py ui_helpers.py web: name: Custom web UI @@ -262,9 +268,9 @@ jobs: run: | git init .ci/liboqs git -C .ci/liboqs remote add origin https://github.com/open-quantum-safe/liboqs.git - # liboqs 0.15.0 - git -C .ci/liboqs fetch --depth=1 origin 97f6b86b1b6d109cfd43cf276ae39c2e776aed80 - git -C .ci/liboqs checkout --detach 97f6b86b1b6d109cfd43cf276ae39c2e776aed80 + # liboqs 0.16.0 + git -C .ci/liboqs fetch --depth=1 origin 5a1a854b0dc9f2141bdc771c555ee60c37950183 + git -C .ci/liboqs checkout --detach 5a1a854b0dc9f2141bdc771c555ee60c37950183 cmake -S .ci/liboqs -B .ci/liboqs/build \ -GNinja \ -DBUILD_SHARED_LIBS=ON \ @@ -273,7 +279,22 @@ jobs: cmake --install .ci/liboqs/build - name: Install Python dependencies - run: python -m pip install -r requirements-dev.txt + run: python -m pip install --require-hashes -r requirements-dev-lock.txt + + - name: Set up Node + uses: actions/setup-node@v7 + with: + node-version: "22" + cache: npm + + - name: Install frontend dependencies + run: npm ci + + - name: Build frontend + run: npm run build + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium - name: Verify native backend is active run: | @@ -314,3 +335,40 @@ jobs: --private-key agent-private.pem \ --output agent-output.txt cmp agent-input.txt agent-output.txt + + - name: Native browser encryption round trip + run: | + SKIP_WEB_BUILD=1 PYTHON=python ./start.sh & + server_pid=$! + trap 'kill "$server_pid" 2>/dev/null || true; wait "$server_pid" 2>/dev/null || true' EXIT + python - <<'PY' + import json + import time + from urllib.request import urlopen + + required = ("generate", "encrypt", "decrypt") + deadline = time.monotonic() + 30 + + while (remaining := deadline - time.monotonic()) > 0: + try: + with urlopen("http://127.0.0.1:4000/api/health", timeout=min(1, remaining)) as response: + health = json.load(response) + except Exception: + health = None + + ready = bool( + health + and health.get("ok") + and health.get("backendReady") + and all(health.get("capabilities", {}).get(name, {}).get("available") for name in required) + ) + if ready: + raise SystemExit(0) + + remaining = deadline - time.monotonic() + if remaining > 0: + time.sleep(min(0.25, remaining)) + + raise SystemExit("Native local app did not become ready within 30 seconds.") + PY + npm run ui-native diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 815f3c2..3bc944b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: run: python -m pip_audit -r requirements-dev-lock.txt - name: Run Python security lint - run: python -m bandit -q -r crypto_core.py pqc_agent_tools.py pqc_app.py ui_helpers.py api_app.py + run: python -m bandit -q -r crypto_core.py pqc_agent_tools.py ui_helpers.py api_app.py - name: Install frontend dependencies run: npm ci @@ -82,15 +82,21 @@ jobs: /tmp/qe-release-test/bin/quantum-encryptor-agent health --json /tmp/qe-release-test/bin/python -I - <<'PY' from pathlib import Path + from importlib.metadata import requires + from importlib.util import find_spec import sys import api_app import pqc_agent_tools + import ui_helpers prefix = Path(sys.prefix).resolve() - for module in (api_app, pqc_agent_tools): + for module in (api_app, pqc_agent_tools, ui_helpers): module_path = Path(module.__file__).resolve() assert module_path.is_relative_to(prefix), (module.__name__, module_path, prefix) + assert find_spec("pqc_app") is None + package_requirements = requires("quantum-encryptor") or () + assert all(not requirement.lower().startswith("streamlit") for requirement in package_requirements) PY - name: Smoke installed web UI @@ -177,9 +183,9 @@ jobs: run: | git init .ci/liboqs git -C .ci/liboqs remote add origin https://github.com/open-quantum-safe/liboqs.git - # liboqs 0.15.0 - git -C .ci/liboqs fetch --depth=1 origin 97f6b86b1b6d109cfd43cf276ae39c2e776aed80 - git -C .ci/liboqs checkout --detach 97f6b86b1b6d109cfd43cf276ae39c2e776aed80 + # liboqs 0.16.0 + git -C .ci/liboqs fetch --depth=1 origin 5a1a854b0dc9f2141bdc771c555ee60c37950183 + git -C .ci/liboqs checkout --detach 5a1a854b0dc9f2141bdc771c555ee60c37950183 cmake -S .ci/liboqs -B .ci/liboqs/build \ -GNinja \ -DBUILD_SHARED_LIBS=ON \ @@ -188,7 +194,7 @@ jobs: cmake --install .ci/liboqs/build - name: Install Python dependencies - run: python -m pip install -r requirements-dev.txt + run: python -m pip install --require-hashes -r requirements-dev-lock.txt - name: Verify migration backends are active run: | diff --git a/.gitignore b/.gitignore index 8cb241b..aba43b7 100644 --- a/.gitignore +++ b/.gitignore @@ -108,11 +108,6 @@ ENV/ env.bak/ venv.bak/ -# Streamlit local state and secrets. Keep committed UI theme config. -.streamlit/* -!.streamlit/ -!.streamlit/config.toml - # Spyder project settings .spyderproject .spyproject diff --git a/.streamlit/config.toml b/.streamlit/config.toml deleted file mode 100644 index d76f1b1..0000000 --- a/.streamlit/config.toml +++ /dev/null @@ -1,7 +0,0 @@ -[theme] -base = "dark" -primaryColor = "#2DD4BF" -backgroundColor = "#000000" -secondaryBackgroundColor = "#111827" -textColor = "#FFFFFF" -font = "sans serif" diff --git a/CHANGELOG.md b/CHANGELOG.md index 349bea6..efd4998 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ This project follows a practical semantic-versioning style. - ML-KEM-768 + X25519 composite key generation and format-v4 encrypted containers. - SHA3-256 hybrid key combiner binding both key shares, X25519 context, suite identifier, and application domain. +- Polished monochrome local web workflows with progressive technical details, + responsive navigation, component tests, accessibility checks, and a native + browser encryption round trip. + +### Changed + +- Retired the Streamlit application, temporary startup fallback, packaging + surface, and superseded reference screenshots. `./start.sh` now serves the + React/Vite interface through the loopback-only Python API as the sole GUI. +- Preserved decrypt-only compatibility for authenticated earlier containers and + private-key formats, including the bounded ML-KEM/Kyber migration path. ### Security diff --git a/README.md b/README.md index 127b1a6..947f795 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,14 @@ A post-quantum cryptography tool for file encryption. New files combine ML-KEM-7 Quantum Encryptor custom web app showing the encrypt file workflow and backend readiness warning

- Dark local web interface for ML-KEM-768 + X25519 key generation, file encryption, decryption, and PEM key inspection. + Monochrome local web interface for ML-KEM-768 + X25519 key generation, file encryption, decryption, and PEM key inspection.

## Features @@ -25,12 +25,12 @@ A post-quantum cryptography tool for file encryption. New files combine ML-KEM-7 - **Post-Quantum/Traditional Security**: Combines ML-KEM-768 with X25519 so confidentiality does not depend on one key-establishment algorithm - **Authenticated File Encryption**: Derives AES-256-GCM keys from both ML-KEM and X25519 shared secrets - **Password-Protected Keys**: Private keys are always encrypted with scrypt-derived AES-256-GCM keys -- **User-Friendly Interface**: Custom local web UI with a Python ASGI API +- **User-Friendly Interface**: Custom local web UI with progressive technical details and a Python ASGI API - **PEM Key Format**: Keys stored in PEM-like format with quantum algorithm extensions ## Screenshots -The backend readiness warning shown here is expected when native `liboqs` is not installed in the local environment. Click any image to open the full screenshot page. +The current browser smoke captures show the responsive Encrypt and Inspect key workflows. Click either image to open the full screenshot page.

@@ -111,21 +111,22 @@ See [docs/SCREENSHOTS.md](docs/SCREENSHOTS.md) for the dedicated screenshot page PYTHON=.venv/bin/python ./test.sh ``` - To run the legacy Streamlit UI during transition: - ```bash - LEGACY_STREAMLIT=1 ./start.sh - ``` - Frontend development can run Vite separately on `127.0.0.1:4001`: ```bash npm run dev ``` -2. Open the web interface in your browser. You can: - - Generate a new post-quantum key pair - - Encrypt files using a recipient's public key - - Decrypt files using your private key - - Access key utilities +2. Open the web interface in your browser. Choose the intent that matches your task: + - **Encrypt**: protect a file for the holder of a recipient public key. + - **Decrypt**: recover a file with the matching encrypted private key and password. + - **Generate keys**: create a new public key and password-protected private key. + - **Inspect key**: check supported key metadata without exposing key material. + + Each workflow starts with plain-language guidance. Expand **Technical details** only when you need suite, format, or key-policy information. + +### Local-only interface privacy + +The custom interface processes selected files through the local Python service at `127.0.0.1`. It does not use browser persistence, telemetry, remote fonts, or remote application assets. The UI does not display plaintext previews, passwords, private-key content, or the local API token. ## Verification @@ -138,8 +139,9 @@ Run the Python test suite: Run the custom frontend checks: ```bash -npm run build +npm run test:unit npm run check +npm run build ``` With the app already running on `127.0.0.1:4000`, run the browser smoke test: @@ -150,16 +152,24 @@ npm run ui-smoke The UI smoke test writes ignored screenshots under `tmp/ui-smoke/`. +When a native `liboqs` installation is available to the running app, also run the real browser encryption round trip: + +```bash +npm run ui-native +``` + +Do not treat the browser smoke test as proof that the native cryptographic backend is installed; it verifies the built interface against the local API contract. `npm run ui-native` verifies key generation, encryption, and decryption through the available native backend. + ### Key Generation -1. Select "Generate Keys" from the sidebar +1. Select "Generate keys" from the workflow navigation 2. Enter and confirm a strong private-key password 3. Generate the keys and download both public and private key files 4. Share your public key with others who want to send you encrypted files ### File Encryption -1. Select "Encrypt File" from the sidebar +1. Select "Encrypt" from the workflow navigation 2. Upload the file you want to encrypt 3. Upload the recipient's public key (.pem file) 4. Specify the output filename @@ -167,15 +177,15 @@ The UI smoke test writes ignored screenshots under `tmp/ui-smoke/`. ### File Decryption -1. Select "Decrypt File" from the sidebar +1. Select "Decrypt" from the workflow navigation 2. Upload the encrypted file (.pqc file) 3. Upload your private key (.pem file) 4. Enter your private-key password 5. Download the decrypted file -## Agent Usage +## Automation Usage -Local automation agents can use the deterministic JSON CLI instead of driving the Streamlit UI. Run commands from the repository workspace and pass only workspace-relative paths. Absolute paths, `..` traversal, symlink escapes, and accidental output overwrites are rejected. +Automation tools can use the deterministic JSON CLI instead of driving the browser interface. Run commands from the repository workspace and pass only workspace-relative paths. Absolute paths, `..` traversal, symlink escapes, and accidental output overwrites are rejected. ```bash mkdir -p keys data @@ -229,7 +239,7 @@ The CLI prints JSON only and never includes plaintext, private keys, passwords, - State-changing local web API requests require a per-process API token and reject non-local browser origins when an `Origin` header is present - The local agent CLI accepts only workspace-relative paths, returns machine-readable JSON without secret material, and writes private keys plus decrypted outputs with owner-only permissions on POSIX systems; non-overwrite output creation uses exclusive file creation - Native `liboqs` is loaded lazily and missing backend support disables key generation/encryption instead of crashing the app -- CI runs Python formatting, linting, type checks, unit tests, custom web UI build/type checks, API client tests, browser UI smoke, isolated installed-wheel checks, Python/npm dependency audits, locked runtime install, and a native `liboqs` integration test job pinned to the matching 0.15.0 release commit; repository CodeQL default setup provides static analysis +- CI runs Python formatting, linting, type checks, unit tests, custom web UI build/type checks, API client tests, browser UI smoke, isolated installed-wheel checks, Python/npm dependency audits, locked runtime install, and a native `liboqs` integration test job pinned to the matching 0.16.0 release commit; repository CodeQL default setup provides static analysis - See [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md) for repository trust boundaries, assets, abuse cases, and invariants - **Disclaimer**: This software has not undergone an independent security audit and should be reviewed before production use @@ -238,8 +248,7 @@ The CLI prints JSON only and never includes plaintext, private keys, passwords, - `crypto_config.py` - Configuration parameters for cryptographic operations - `crypto_core.py` - Core cryptographic functions (key generation, encryption, decryption) - `api_app.py` - Local ASGI API and static web UI server -- `pqc_agent_tools.py` - Local JSON CLI for agentic workflows -- `pqc_app.py` - Legacy Streamlit web application interface +- `pqc_agent_tools.py` - Local JSON CLI for automation workflows - `web/` - React frontend source for the custom UI - `package.json` / `vite.config.ts` - Frontend build configuration - `ui_helpers.py` - UI-safe filename helpers diff --git a/api_app.py b/api_app.py index 8d9826c..465d1d4 100644 --- a/api_app.py +++ b/api_app.py @@ -299,23 +299,53 @@ async def _read_upload_text(upload: UploadFile, max_bytes: int, label: str) -> s raise ApiError(400, "invalid_text", f"{label} must be a UTF-8 text file.") from exc +def _capability(available: bool, reason: str = "") -> dict[str, bool | str]: + return {"available": available, "reason": "" if available else reason} + + def _health_payload() -> dict[str, Any]: try: active_kem_component = core.resolve_kem_algorithm(cfg.KEM_ALG) - backend_ready = True - backend_message = "Post-quantum backend ready." + current_backend_ready = True except Exception as exc: active_kem_component = cfg.KEM_ALG - backend_ready = False - backend_message = ( + current_backend_ready = False + logger.warning("Post-quantum backend readiness check failed: %s", exc) + + try: + decrypt_ready = bool(core.available_decryption_kem_algorithms()) + except Exception as exc: + decrypt_ready = False + logger.warning("Decryption backend readiness check failed: %s", exc) + + capabilities = { + "inspect": _capability(True), + "generate": _capability( + current_backend_ready, + "ML-KEM-768 is unavailable for new key generation.", + ), + "encrypt": _capability( + current_backend_ready, + "ML-KEM-768 is unavailable for new encryption.", + ), + "decrypt": _capability( + decrypt_ready, + "No supported post-quantum decryption backend is available.", + ), + } + backend_message = ( + "Post-quantum backend ready." + if current_backend_ready + else ( "The ML-KEM backend is not ready, so new keys and ciphertexts cannot be created. " - "Compatible legacy archives may still be decryptable." + "Compatible encrypted archives may still be decryptable." ) - logger.warning("Post-quantum backend readiness check failed: %s", exc) + ) return { - "backendReady": backend_ready, + "backendReady": current_backend_ready, "backendMessage": backend_message, + "capabilities": capabilities, "formatVersion": cfg.FORMAT_VERSION, "kem": cfg.HYBRID_KEM_ALG, "kemComponent": active_kem_component, diff --git a/crypto_core.py b/crypto_core.py index 737dcb9..e516a78 100644 --- a/crypto_core.py +++ b/crypto_core.py @@ -220,6 +220,12 @@ def resolve_kem_algorithm(kem_alg: Optional[str] = None) -> str: ) +def available_decryption_kem_algorithms() -> Tuple[str, ...]: + """Return enabled, application-supported KEM identities in preference order.""" + enabled = set(_enabled_kem_mechanisms()) + return tuple(candidate for candidate in (cfg.KEM_ALG, *cfg.LEGACY_KEM_ALGS) if candidate in enabled) + + def resolve_decryption_kem_algorithms(suite: str) -> Tuple[str, ...]: """Resolve exact KEM identities allowed to decrypt a key/container suite.""" if suite == cfg.HYBRID_KEM_ALG: @@ -229,8 +235,7 @@ def resolve_decryption_kem_algorithms(suite: str) -> Tuple[str, ...]: if suite != cfg.LEGACY_HYBRID_KEM_ALG: raise UnsupportedAlgorithmError(f"Unsupported key or container suite: {suite!r}") - enabled = set(_enabled_kem_mechanisms()) - candidates = tuple(candidate for candidate in (cfg.KEM_ALG, *cfg.LEGACY_KEM_ALGS) if candidate in enabled) + candidates = available_decryption_kem_algorithms() if candidates: return candidates raise CryptoDependencyError( diff --git a/docs/API.md b/docs/API.md index b6c27f9..0ea8946 100644 --- a/docs/API.md +++ b/docs/API.md @@ -39,6 +39,23 @@ The custom web UI is served by `api_app.py` on `127.0.0.1` by default. `GET /api When a browser sends an `Origin` header on a state-changing API request, the server only accepts local origins beginning with `http://127.0.0.1:` or `http://localhost:`. Requests without the local API token are rejected before route handlers parse uploaded files or form data. +### Health capability contract + +`GET /api/health` includes additive, operation-specific readiness information. New clients should use these capabilities to decide whether to enable each workflow: + +``` +{ + "capabilities": { + "inspect": { "available": true, "reason": "" }, + "generate": { "available": true, "reason": "" }, + "encrypt": { "available": true, "reason": "" }, + "decrypt": { "available": true, "reason": "" } + } +} +``` + +`backendReady` and `backendMessage` remain in the response for compatibility, while new clients use operation-specific capabilities. A capability `reason` is a safe user-facing summary of an unavailable operation; it is not a raw backend exception. The health response is marked `Cache-Control: no-store` and `Pragma: no-cache` because it includes the per-process local API token. + ### Agent JSON Contract Successful command output: diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 9c96c51..f5283a1 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -26,8 +26,8 @@ Use this checklist before publishing a new release. python -m pip install -r requirements-dev.txt npm ci python -m black --check . -python -m flake8 api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py pqc_app.py ui_helpers.py setup.py tests -python -m mypy api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py pqc_app.py ui_helpers.py tests +python -m flake8 api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py ui_helpers.py setup.py tests +python -m mypy api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py ui_helpers.py tests ./test.sh --cov=crypto_core --cov=pqc_agent_tools --cov=ui_helpers --cov=api_app --cov-report=term-missing --cov-fail-under=80 npm run build npm run check diff --git a/docs/SCREENSHOTS.md b/docs/SCREENSHOTS.md index 29f7949..7acda34 100644 --- a/docs/SCREENSHOTS.md +++ b/docs/SCREENSHOTS.md @@ -1,31 +1,11 @@ # Application Screenshots -These screenshots show the custom local web UI. They were captured from the local app with native `liboqs` unavailable, so the backend readiness warning and disabled key generation or file processing controls are expected. +These screenshots show the current monochrome custom local web UI. The interface uses progressive disclosure: plain-language task guidance appears first, while suite, format, and key-policy information stays inside **Technical details** until it is needed. The desktop Encrypt capture shows format version 4 and the `ML-KEM-768+X25519-v2` hybrid suite; the mobile Inspect key capture shows responsive workflow navigation and public-key metadata only. ## Custom Web Encrypt Workflow -![Custom web encrypt workflow with backend readiness warning](screenshots/custom-web-encrypt-workflow.png) +![Custom web encrypt workflow with local engine ready and technical details expanded](screenshots/custom-web-encrypt-workflow.png) ## Custom Web Mobile Inspect ![Custom web mobile key inspection workflow](screenshots/custom-web-mobile-inspect.png) - -## Legacy Streamlit Reference - -The default `./start.sh` path serves the custom local web UI. These older screenshots remain as a reference for the legacy Streamlit interface available through `LEGACY_STREAMLIT=1 ./start.sh`. - -### Generate Keys - -![Legacy generate keys workflow with backend readiness warning](screenshots/generate-keys-backend-warning.jpg) - -### Encrypt File - -![Legacy encrypt file workflow](screenshots/encrypt-file-workflow.jpg) - -### Decrypt File - -![Legacy decrypt file workflow](screenshots/decrypt-file-workflow.jpg) - -### Key Utilities - -![Legacy key utilities workflow](screenshots/key-utilities-workflow.jpg) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 08941fd..2c5a62b 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -76,7 +76,7 @@ The application is designed to protect against the following threats: - Legacy hybrid public keys are rejected for encryption and must be regenerated under the current suite. - The UI and core encryption path enforce a 100 MiB plaintext in-memory file limit; decryption accepts only the bounded encrypted-container size needed for header, KEM ciphertext, nonce, and tag overhead. - The local web API requires a per-process `X-Quantum-Encryptor-Token` for state-changing `/api/*` requests and rejects non-local browser origins when an `Origin` header is present. -- Download filenames are reduced to local filenames before being passed to Streamlit. +- Download filenames are reduced to local filenames before the API returns them to the browser. - Private-key password protection requires at least 16 characters, at least 5 unique characters, and rejects known weak values in the core, UI, and agent CLI. - Unencrypted private keys are rejected in the core, UI, and agent CLI. - The core module defines its own logger but leaves root logging configuration to application entry points. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index e0825b8..c70c3ab 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -15,7 +15,7 @@ Quantum Encryptor protects local files with post-quantum key encapsulation and a ## Trust Boundaries -- Streamlit uploads are untrusted user-controlled files. +- Browser uploads to the local web API are untrusted user-controlled files. - Agent CLI arguments and environment variables are untrusted automation inputs. - Public/private PEM files and `.pqc` files are attacker-controlled until parsed and authenticated. - Native `liboqs` and Python `cryptography` are trusted dependencies but may be unavailable or misconfigured. diff --git a/docs/screenshots/custom-web-encrypt-workflow.png b/docs/screenshots/custom-web-encrypt-workflow.png index 2b36c3d..9a423ed 100644 Binary files a/docs/screenshots/custom-web-encrypt-workflow.png and b/docs/screenshots/custom-web-encrypt-workflow.png differ diff --git a/docs/screenshots/custom-web-mobile-inspect.png b/docs/screenshots/custom-web-mobile-inspect.png index 3405895..a27f59d 100644 Binary files a/docs/screenshots/custom-web-mobile-inspect.png and b/docs/screenshots/custom-web-mobile-inspect.png differ diff --git a/docs/screenshots/decrypt-file-workflow.jpg b/docs/screenshots/decrypt-file-workflow.jpg deleted file mode 100644 index 322e96c..0000000 Binary files a/docs/screenshots/decrypt-file-workflow.jpg and /dev/null differ diff --git a/docs/screenshots/encrypt-file-workflow.jpg b/docs/screenshots/encrypt-file-workflow.jpg deleted file mode 100644 index b5255b8..0000000 Binary files a/docs/screenshots/encrypt-file-workflow.jpg and /dev/null differ diff --git a/docs/screenshots/generate-keys-backend-warning.jpg b/docs/screenshots/generate-keys-backend-warning.jpg deleted file mode 100644 index ee860c8..0000000 Binary files a/docs/screenshots/generate-keys-backend-warning.jpg and /dev/null differ diff --git a/docs/screenshots/key-utilities-workflow.jpg b/docs/screenshots/key-utilities-workflow.jpg deleted file mode 100644 index 6d66f84..0000000 Binary files a/docs/screenshots/key-utilities-workflow.jpg and /dev/null differ diff --git a/package-lock.json b/package-lock.json index 412e548..f6b9712 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,17 +12,208 @@ "react-dom": "^19.2.7" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "jsdom": "26.1.0", "playwright": "^1.61.1", "typescript": "^7.0.2", - "vite": "^8.1.5" + "vite": "^8.1.5", + "vitest": "^4.1.10" }, "engines": { "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@axe-core/playwright": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.12.1.tgz", + "integrity": "sha512-rMd7xriptqKpP+w5265i4Hdkv2X5kbu6uiBi/B2I7uf3hieRBM3qDCfaKPtxfiYb2mKXfF+yLODJwIx+Jv1GDw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.12.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -57,6 +248,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -179,9 +377,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -199,9 +394,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -219,9 +411,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -239,9 +428,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -259,9 +445,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -279,9 +462,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -368,6 +548,102 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -379,6 +655,38 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -765,96 +1073,524 @@ } } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12.0.0" + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "picomatch": "^3 || ^4" + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { - "picomatch": { + "msw": { + "optional": true + }, + "vite": { "optional": true } } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", "dev": true, - "license": "MPL-2.0", + "license": "MIT", "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "url": "https://opencollective.com/vitest" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", "dev": true, - "license": "MPL-2.0", - "optional": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axe-core": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.1.tgz", + "integrity": "sha512-s7iGf5GaVMxEG0ENN9x+xTr7GFZCb1ZP/1uATUpCEK2X78nDB3RwbtFCo9pGAf9ru+VwoQ464DkaLEeRM08wJA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, "os": [ "android" ], @@ -958,9 +1694,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -982,9 +1715,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1006,9 +1736,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1030,9 +1757,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1088,6 +1812,50 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -1107,6 +1875,47 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nwsapi": { + "version": "2.2.24", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.24.tgz", + "integrity": "sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1203,6 +2012,31 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -1224,6 +2058,27 @@ "react": "^19.2.7" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -1258,12 +2113,46 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1274,6 +2163,57 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1291,6 +2231,62 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -1411,6 +2407,213 @@ "optional": true } } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 53f6745..6af6ac9 100644 --- a/package.json +++ b/package.json @@ -9,20 +9,30 @@ "scripts": { "dev": "vite --host 127.0.0.1 --port 4001", "build": "tsc --noEmit && vite build", - "check": "tsc --noEmit && npm run test:api", + "check": "tsc --noEmit && npm run test:api && npm run test:unit", "test:api": "node --test scripts/api_client.test.mjs", - "ui-smoke": "node scripts/ui_smoke.mjs" + "test:unit": "vitest run", + "test:unit:watch": "vitest", + "ui-smoke": "node scripts/ui_smoke.mjs", + "ui-native": "node scripts/ui_native_smoke.mjs" }, "dependencies": { "react": "^19.2.7", "react-dom": "^19.2.7" }, "devDependencies": { + "@axe-core/playwright": "^4.12.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "jsdom": "26.1.0", "playwright": "^1.61.1", "typescript": "^7.0.2", - "vite": "^8.1.5" + "vite": "^8.1.5", + "vitest": "^4.1.10" } } diff --git a/pqc_app.py b/pqc_app.py deleted file mode 100644 index c80728f..0000000 --- a/pqc_app.py +++ /dev/null @@ -1,492 +0,0 @@ -# pqc_app.py -import streamlit as st -import logging -from pathlib import Path -import mimetypes # For guessing download mime type -import re - -# Import core logic and config -from crypto_config import cfg -import crypto_core as core -from ui_helpers import format_key_info_for_display, guess_decrypted_filename - -# --- Basic Logging Setup --- -# Configure logging level and format -# Streamlit can sometimes interfere with basicConfig, setting level explicitly helps. -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s - %(levelname)s - [%(module)s] - %(message)s", -) -logger = logging.getLogger(__name__) - -# --- Streamlit Page Configuration --- -st.set_page_config( - page_title="PQC Pro Encryptor", - layout="wide", - initial_sidebar_state="expanded", - menu_items={"About": f""" - **PQC Pro Encryptor v{cfg.FORMAT_VERSION}.0** - - Uses **{cfg.KEM_ALG}** (PQC KEM) + **AES-256-GCM** (DEM) with HKDF. - Private keys are always password protected (scrypt + AES-GCM). - - **Disclaimer:** Security relies on correct implementation, secure key management, - and the underlying cryptographic primitives. Requires auditing for production use. - **The developer provides no warranty.** Use at your own risk. - """}, -) - -# --- Helper Functions for GUI --- - - -def display_pem_download(pem_string: str, filename: str, key_label: str): - """Provides a download button for a PEM string.""" - try: - st.download_button( - label=f"Download {key_label} ({filename})", - data=pem_string.encode("ascii"), # PEM is ASCII - file_name=filename, - mime="application/x-pem-file", - ) - except Exception as e: - logger.exception(f"Error creating download button for {key_label}: {e}") - st.error(f"Could not prepare {key_label} for download.") - - -def format_size(byte_count: int) -> str: - """Format a byte count for user-facing validation messages.""" - mib = byte_count / (1024 * 1024) - return f"{mib:.1f} MiB" - - -def uploaded_file_too_large(uploaded_file, max_bytes: int | None = None) -> bool: - """Return whether a Streamlit upload exceeds the configured in-memory limit.""" - size = getattr(uploaded_file, "size", None) - limit = cfg.MAX_FILE_BYTES if max_bytes is None else max_bytes - return size is not None and size > limit - - -def uploaded_key_file_too_large(uploaded_file, max_bytes: int | None = None) -> bool: - """Return whether a Streamlit PEM/key upload exceeds the configured limit.""" - limit = cfg.MAX_PEM_BYTES if max_bytes is None else max_bytes - return uploaded_file_too_large(uploaded_file, limit) - - -def sanitize_download_filename(filename: str, fallback: str) -> str: - """Constrain user-controlled download names to a simple local filename.""" - candidate = Path(filename or "").name.strip() - candidate = re.sub(r"[\x00-\x1f\x7f]+", "", candidate) - return candidate or fallback - - -def validate_private_key_password_for_ui(password: str) -> tuple[bool, str | None]: - """Validate private-key password text with the core policy.""" - try: - core.validate_private_key_password(password) - return True, None - except (core.PasswordRequiredError, core.WeakPasswordError) as exc: - return False, str(exc) - - -# --- Main Application UI --- - -try: - active_kem_component = core.resolve_kem_algorithm(cfg.KEM_ALG) - kem_status_message = None -except Exception as exc: - active_kem_component = cfg.KEM_ALG - kem_status_message = ( - "The ML-KEM backend is not ready, so new keys and ciphertexts cannot be created. " - "Compatible legacy archives may still be decryptable." - ) - logger.warning("Unable to resolve configured KEM algorithm: %s", exc) - -st.title("🛡️ PQC Pro File Encryption / Decryption") -st.markdown(f""" -Utilizes **{cfg.HYBRID_KEM_ALG}** + **AES-256-GCM** hybrid encryption. -Supports password-protected private keys (PEM format). -""") - -st.sidebar.header("Operations") -operation = st.sidebar.radio( - "Choose action:", - ["Generate Keys", "Encrypt File", "Decrypt File", "Key Utilities"], - key="main_operation", -) - -st.sidebar.markdown("---") -st.sidebar.info( - f"Version: {cfg.FORMAT_VERSION}.0\nSuite: {cfg.HYBRID_KEM_ALG}\n" - f"ML-KEM backend: {active_kem_component}\nDEM: AES-256-GCM" -) -if kem_status_message: - st.sidebar.warning(kem_status_message) - -# State Management (using session state is generally better) -# Empty password field defaults are not credential values. -if "password_gen" not in st.session_state: - st.session_state.password_gen = "" # nosec B105 -if "password_gen_confirm" not in st.session_state: - st.session_state.password_gen_confirm = "" # nosec B105 -if "password_decrypt" not in st.session_state: - st.session_state.password_decrypt = "" # nosec B105 - -# === Key Generation === -if operation == "Generate Keys": - st.header("🔑 Generate PQC Key Pair") - st.markdown(f"Generates a **{cfg.HYBRID_KEM_ALG}** public/private key pair (PEM format).") - st.info(f"Using hybrid suite: **{cfg.HYBRID_KEM_ALG}**") - - st.subheader("Private Key Password") - password_valid = False - st.session_state.password_gen = st.text_input("Enter Password:", type="password", key="gen_pw1") - st.session_state.password_gen_confirm = st.text_input("Confirm Password:", type="password", key="gen_pw2") - if st.session_state.password_gen or st.session_state.password_gen_confirm: - if not st.session_state.password_gen: - st.warning("Password cannot be empty.") - elif st.session_state.password_gen != st.session_state.password_gen_confirm: - st.warning("Passwords do not match.") - else: - password_valid, password_error = validate_private_key_password_for_ui(st.session_state.password_gen) - if password_valid: - st.success("Passwords match.") - else: - st.warning(password_error) - else: - st.warning("A password is required. Unencrypted private keys are not supported.") - disable_gen_button = not password_valid - - st.markdown("---") - if st.button( - f"Generate {cfg.HYBRID_KEM_ALG} Key Pair", - key="gen_button", - disabled=disable_gen_button or bool(kem_status_message), - ): - final_password = st.session_state.password_gen if password_valid else None - - with st.status(f"Generating {cfg.HYBRID_KEM_ALG} keys...", expanded=True) as status: - st.write("Generating independent X25519 and ML-KEM key pairs...") - raw_pub_key, raw_priv_key = core.generate_hybrid_keys(active_kem_component) - - if raw_pub_key and raw_priv_key: - st.write("Raw keys generated.") - st.write("Formatting Public Key (PEM)...") - pub_pem = core.save_key_pem(raw_pub_key, cfg.HYBRID_KEM_ALG, "public") - - st.write("Formatting Private Key (PEM)...") - st.write("(Encrypting private key with password...)") - priv_pem = core.save_key_pem( - raw_priv_key, - cfg.HYBRID_KEM_ALG, - "private", - password=final_password, - ) - - # Cleanup raw keys immediately after PEM generation - del raw_pub_key - del raw_priv_key - - if pub_pem and priv_pem: - status.update(label="Key generation complete!", state="complete") - st.success("Key pair generated successfully!") - st.markdown("---") - st.subheader("Download Your Keys (PEM Format)") - st.warning( - "🚨 **CRITICAL:** Securely store the **Private Key** file. " - "Remember the password. Loss = permanent data loss." - ) - - pub_filename = "ml-kem-768_x25519_public.pem" - priv_filename = "ml-kem-768_x25519_private.pem" - - col1, col2 = st.columns(2) - with col1: - display_pem_download(pub_pem, pub_filename, "Public Key") - with col2: - display_pem_download(priv_pem, priv_filename, "Private Key") - - st.markdown("---") - st.info("Share the Public Key (`.pem`) file with others to allow them to encrypt files for you.") - - else: - err_msg = "Failed to format keys into PEM format." - logger.error(err_msg) - status.update(label=err_msg, state="error") - st.error(err_msg) - else: - err_msg = f"Failed to generate raw keys for {cfg.HYBRID_KEM_ALG}." - logger.error(err_msg) - status.update(label=err_msg, state="error") - st.error(err_msg + " Check logs for details. Is liboqs working?") - -# === Encryption === -elif operation == "Encrypt File": - st.header("⬆️ Encrypt a File") - st.markdown("Encrypt using the recipient's **Public Key** (PEM format).") - - uploaded_file = st.file_uploader("1. Choose File to Encrypt", type=None, key="enc_file_input") - public_key_pem_file = st.file_uploader( - "2. Upload Recipient's Public Key (.pem)", type=["pem"], key="enc_pubkey_input" - ) - - if uploaded_file and public_key_pem_file: - st.markdown("---") - input_too_large = uploaded_file_too_large(uploaded_file) - if input_too_large: - st.error( - f"Selected file is {format_size(uploaded_file.size)}. " - f"The maximum supported size is {format_size(cfg.MAX_FILE_BYTES)}." - ) - if uploaded_key_file_too_large(public_key_pem_file): - st.error( - f"Public key file is {format_size(public_key_pem_file.size)}. " - f"The maximum supported PEM size is {format_size(cfg.MAX_PEM_BYTES)}." - ) - st.stop() - pub_pem_content: str | None - try: - pub_pem_content = public_key_pem_file.getvalue().decode("utf-8") - except Exception as e: - logger.exception("Error reading public key file: %s", e) - st.error("Could not read the public key file.") - pub_pem_content = None # Halt further processing - - if pub_pem_content: - # Load public key (password ignored by load_key_pem for public keys) - pub_key_bytes, kem_alg_from_key, key_type = core.load_key_pem(pub_pem_content) - - if pub_key_bytes and kem_alg_from_key == cfg.HYBRID_KEM_ALG and key_type == "public": - st.success(f"Public Key loaded successfully (Algorithm: {kem_alg_from_key}).") - - original_filename = Path(uploaded_file.name) - suggested_output_filename = f"{original_filename.stem}_encrypted.pqc" - output_filename = st.text_input( - "3. Encrypted file name:", - value=suggested_output_filename, - key="enc_output_name", - ) - output_filename = sanitize_download_filename(output_filename, suggested_output_filename) - - if not output_filename: - st.warning("Please provide a name for the encrypted file.") - elif st.button("Encrypt File", key="enc_button", disabled=input_too_large): - input_data = uploaded_file.getvalue() - if len(input_data) == 0: - st.warning( - "Input file is empty. Encryption will proceed, resulting file will contain " - "header and metadata only." - ) - elif len(input_data) > cfg.MAX_FILE_BYTES: - st.error("Selected file exceeds the maximum supported size.") - st.stop() - - with st.status(f"Encrypting '{uploaded_file.name}'...", expanded=False) as status: - try: - status.write("Reading input file...") # Already done by getvalue() - status.write(f"Performing {cfg.HYBRID_KEM_ALG} + AES-GCM encryption...") - encrypted_blob = core.encrypt_file_pro(input_data, pub_key_bytes, kem_alg_from_key) - status.write("Cleaning up...") - del input_data - del pub_key_bytes - - if encrypted_blob: - status.update(label="Encryption successful!", state="complete") - st.success("File encrypted successfully!") - st.download_button( - label=f"Download Encrypted File ({output_filename})", - data=encrypted_blob, - file_name=output_filename, - mime="application/octet-stream", - key="enc_download_button", - ) - else: - err_msg = "Encryption process failed." - logger.error(err_msg + " (Core function returned None)") - status.update(label=err_msg, state="error") - st.error(err_msg + " Check logs.") - - except Exception as e: - logger.exception(f"Unhandled exception during encryption action: {e}") - status.update(label="Encryption Error", state="error") - st.error("An unexpected error occurred during encryption. Check server logs for details.") - - elif key_type == "private": - st.error("Error: The uploaded key file appears to be a Private Key, not a Public Key.") - else: - st.error( - "Failed to load or validate the Public Key from the provided PEM file. Is it correctly formatted?" - ) - - -# === Decryption === -elif operation == "Decrypt File": - st.header("⬇️ Decrypt a File") - st.markdown("Decrypt a `.pqc` file using **your** corresponding **Private Key** (PEM format).") - - encrypted_file = st.file_uploader("1. Choose Encrypted File (.pqc)", type=["pqc"], key="dec_file_input") - private_key_pem_file = st.file_uploader("2. Upload Your Private Key (.pem)", type=["pem"], key="dec_privkey_input") - - if encrypted_file and private_key_pem_file: - st.markdown("---") - encrypted_too_large = uploaded_file_too_large(encrypted_file, cfg.MAX_ENCRYPTED_FILE_BYTES) - if encrypted_too_large: - st.error( - f"Selected encrypted file is {format_size(encrypted_file.size)}. " - f"The maximum supported encrypted file size is {format_size(cfg.MAX_ENCRYPTED_FILE_BYTES)}." - ) - if uploaded_key_file_too_large(private_key_pem_file): - st.error( - f"Private key file is {format_size(private_key_pem_file.size)}. " - f"The maximum supported PEM size is {format_size(cfg.MAX_PEM_BYTES)}." - ) - st.stop() - # First, inspect the private key security metadata. - priv_pem_content: str | None - try: - priv_pem_content = private_key_pem_file.getvalue().decode("utf-8") - priv_info = core.inspect_key_pem_strict(priv_pem_content) - priv_type = str(priv_info.get("key_type", "")).title() - except Exception as e: - logger.exception("Error reading private key file: %s", e) - st.error("Could not read a supported encrypted private key file.") - priv_pem_content = None # Halt - - password_provided = False - if priv_pem_content: - if priv_type == "Private": - st.subheader("Password Required for Private Key") - st.session_state.password_decrypt = st.text_input( - "Enter Private Key Password:", type="password", key="dec_pw" - ) - if st.session_state.password_decrypt: - password_provided = True - else: - st.warning("This private key is encrypted. Please enter the password.") - elif priv_type == "Public": - st.error( - "Error: The uploaded key file appears to be a Public Key. A Private Key is required for decryption." - ) - priv_pem_content = None # Halt - elif not priv_type: - st.error("Could not determine key type or algorithm from the Private Key file. Is it a valid PEM?") - priv_pem_content = None # Halt - - if priv_pem_content and password_provided: - original_filename = Path(encrypted_file.name) - suggested_output_filename = guess_decrypted_filename(original_filename) - output_filename = st.text_input( - "3. Decrypted file name:", - value=suggested_output_filename, - key="dec_output_name", - ) - output_filename = sanitize_download_filename(output_filename, suggested_output_filename) - - disable_dec_button = not output_filename or not password_provided or encrypted_too_large - - if st.button("Decrypt File", key="dec_button", disabled=disable_dec_button): - encrypted_blob = encrypted_file.getvalue() - if len(encrypted_blob) == 0: - st.error("Encrypted file appears to be empty. Cannot decrypt.") - else: - if len(encrypted_blob) > cfg.MAX_ENCRYPTED_FILE_BYTES: - st.error("Selected encrypted file exceeds the maximum supported encrypted file size.") - st.stop() - - with st.status(f"Decrypting '{encrypted_file.name}'...", expanded=False) as status: - try: - status.write("Loading private key...") - priv_key_bytes, kem_alg_key, key_type = core.load_key_pem( - priv_pem_content, - password=st.session_state.password_decrypt, - ) - - if not priv_key_bytes or key_type != "private": - err_msg = "Failed to load private key." - err_msg += " Check if password is correct." - logger.error(err_msg + " (Core function returned None or wrong key type)") - status.update(label=err_msg, state="error") - st.error(err_msg) - else: - status.write(f"Private key loaded (Algorithm: {kem_alg_key}).") - status.write("Reading encrypted data...") # Already done by getvalue() - status.write("Performing decryption and authentication...") - - decrypted_data, _detected_alg = core.decrypt_file_pro( - encrypted_blob, - priv_key_bytes, - expected_kem_alg=kem_alg_key, - ) - - status.write("Cleaning up...") - del encrypted_blob - del priv_key_bytes # Very important! - - if decrypted_data is not None: - status.update(label="Decryption successful!", state="complete") - st.success("File decrypted successfully!") - - # Guess mime type for download - mime_type, _ = mimetypes.guess_type(output_filename) - mime_type = mime_type or "application/octet-stream" # Default - - st.download_button( - label=f"Download Decrypted File ({output_filename})", - data=decrypted_data, - file_name=output_filename, - mime=mime_type, - key="dec_download_button", - ) - # Clean up decrypted data after download button is prepared - del decrypted_data - else: - err_msg = "Decryption failed." - logger.error( - err_msg + " (Core function returned None - check for auth errors in logs)" - ) - status.update(label=err_msg, state="error") - st.error( - err_msg + " Possible reasons: incorrect private key, wrong password, " - "file corrupted/tampered with." - ) - - except Exception as e: - logger.exception(f"Unhandled exception during decryption action: {e}") - status.update(label="Decryption Error", state="error") - st.error("An unexpected error occurred during decryption. Check server logs for details.") - - -# === Key Utilities === -elif operation == "Key Utilities": - st.header("🛠️ Key Utilities") - st.markdown("Inspect a PEM key file.") - - key_util_file = st.file_uploader("Upload Key File (.pem)", type=["pem"], key="util_key_input") - - if key_util_file: - if uploaded_key_file_too_large(key_util_file): - st.error( - f"Key file is {format_size(key_util_file.size)}. " - f"The maximum supported PEM size is {format_size(cfg.MAX_PEM_BYTES)}." - ) - st.stop() - try: - pem_content = key_util_file.getvalue().decode("utf-8") - key_info = core.inspect_key_pem_strict(pem_content) - display_info = format_key_info_for_display(key_info) - - st.success("Key file parsed successfully.") - st.markdown("---") - for label, value in display_info.items(): - st.write(f"**{label}:** `{value}`") - st.markdown("---") - st.info( - "Note: This checks supported key metadata and security policy. " - "It does not prove the key matches a ciphertext." - ) - - except (core.InvalidKeyFormatError, core.UnencryptedPrivateKeyError, core.UnsupportedKDFError) as e: - logger.warning("Unsupported key file in utility inspector: %s", e) - st.error("Unsupported or insecure PEM key file.") - except Exception as e: - logger.exception(f"Error inspecting key file: {e}") - st.error("Could not read or parse the key file.") diff --git a/pyproject.toml b/pyproject.toml index e0a3aa4..ec2fda5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,6 @@ authors = [ dependencies = [ "liboqs-python>=0.14.1,<1.0", "cryptography>=42.0.0", - "streamlit>=1.32.0", "starlette>=1.3.1,<2", "uvicorn>=0.49.0,<1", "python-multipart>=0.0.32,<1" @@ -39,7 +38,7 @@ Issues = "https://github.com/brainx/Quantum-Encryptor/issues" quantum-encryptor-agent = "pqc_agent_tools:main" [tool.setuptools] -py-modules = ["api_app", "crypto_config", "crypto_core", "pqc_agent_tools", "pqc_app", "ui_helpers"] +py-modules = ["api_app", "crypto_config", "crypto_core", "pqc_agent_tools", "ui_helpers"] [tool.setuptools.data-files] "static/app" = ["static/app/index.html"] diff --git a/requirements-dev-lock.txt b/requirements-dev-lock.txt index a3c453b..890e129 100644 --- a/requirements-dev-lock.txt +++ b/requirements-dev-lock.txt @@ -8,16 +8,10 @@ alabaster==1.0.0 \ --hash=sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e \ --hash=sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b # via sphinx -altair==6.2.2 \ - --hash=sha256:94014f8ad8617c3cb163d1137359cd6db5ba134b9b46d93cfd8b609fd245a583 \ - --hash=sha256:a1ff9d9cfe81c75414641826312b9471780e19d39293ba0b012933f6b6cba0fe - # via streamlit anyio==4.14.2 \ --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f - # via - # starlette - # streamlit + # via starlette ast-serialize==0.6.0 \ --hash=sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f \ --hash=sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b \ @@ -54,12 +48,6 @@ ast-serialize==0.6.0 \ --hash=sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad \ --hash=sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554 # via mypy -attrs==26.1.0 \ - --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ - --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 - # via - # jsonschema - # referencing babel==2.18.0 \ --hash=sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d \ --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35 @@ -97,10 +85,6 @@ black==26.5.1 \ --hash=sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294 \ --hash=sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae # via -r requirements-dev.txt -blinker==1.9.0 \ - --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ - --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc - # via streamlit boolean-py==5.0 \ --hash=sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95 \ --hash=sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9 @@ -117,10 +101,6 @@ cachecontrol[filecache]==0.14.4 \ # via # cachecontrol # pip-audit -cachetools==7.1.4 \ - --hash=sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54 \ - --hash=sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6 - # via streamlit certifi==2026.6.17 \ --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \ --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db @@ -328,7 +308,6 @@ click==8.4.2 \ # via # black # pip-tools - # streamlit # uvicorn coverage[toml]==7.15.2 \ --hash=sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2 \ @@ -470,7 +449,9 @@ cryptography==49.0.0 \ --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \ --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \ --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b - # via -r requirements.txt + # via + # -r requirements.txt + # secretstorage cyclonedx-python-lib==11.11.0 \ --hash=sha256:3049fc83e06a059b5c5907a527625a8ed5073caab10607ed4c9e5503b590fd44 \ --hash=sha256:4b3194db72b613717f2912447e67ab618c75ff7dcac6c4af3c0e9e1ac617c102 @@ -495,14 +476,6 @@ flake8==7.3.0 \ --hash=sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e \ --hash=sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872 # via -r requirements-dev.txt -gitdb==4.0.12 \ - --hash=sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571 \ - --hash=sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf - # via gitpython -gitpython==3.1.52 \ - --hash=sha256:79a36ee1f83523214a3f72d56cf1c4e490d577dc61af77e43dfe5862bd9da01a \ - --hash=sha256:de0a8ad86274c6e75ae8b37dd055ba68f19818c813108642263227b20775b48e - # via streamlit h11==0.16.0 \ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 @@ -558,9 +531,7 @@ httptools==0.8.0 \ --hash=sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a \ --hash=sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0 \ --hash=sha256:fe2a4c95aeba2209434e7b31172da572846cae8ca0bf1e7013e61b99fbbf5e72 - # via - # -r requirements.txt - # streamlit + # via -r requirements.txt id==1.6.1 \ --hash=sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069 \ --hash=sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca @@ -579,10 +550,6 @@ iniconfig==2.3.0 \ --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 # via pytest -itsdangerous==2.2.0 \ - --hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \ - --hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173 - # via streamlit jaraco-classes==3.4.0 \ --hash=sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd \ --hash=sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790 @@ -604,24 +571,14 @@ jeepney==0.9.0 ; sys_platform == "linux" \ jinja2==3.1.6 \ --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 - # via - # altair - # pydeck - # sphinx -jsonschema==4.26.0 \ - --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ - --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce - # via altair -jsonschema-specifications==2025.9.1 \ - --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ - --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d - # via jsonschema + # via sphinx keyring==25.7.0 \ --hash=sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f \ --hash=sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b # via twine -liboqs-python==0.15.0 \ - --hash=sha256:969b27c65c99f973f8c991472151331814c9f02642115fe4d6391bcdc33433a4 +liboqs-python==0.16.0 \ + --hash=sha256:50bb11a9767b88f68099cedef8de09f798f31eaba1aa18b483988bfcd50df440 \ + --hash=sha256:ee01445ccfbeab8dced956a642ce1eb5ba69438caba7ec291eea14083627d5c0 # via -r requirements.txt librt==0.13.0 \ --hash=sha256:05d96b80b95d3a2721b619f8982b8558848b04875bb4772fd54842b59f61dd97 \ @@ -951,10 +908,6 @@ mypy-extensions==1.1.0 \ # via # black # mypy -narwhals==2.24.0 \ - --hash=sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489 \ - --hash=sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d - # via altair nh3==0.3.6 \ --hash=sha256:082675ff87b9385ec430ffe6d5847ba7456cc39b73720cd4add472f9f4cffd56 \ --hash=sha256:2411e8c3cee81a1ddd62c2a5d50585c28aa5566d373ad1db92536b95ddb24ef2 \ @@ -984,83 +937,6 @@ nh3==0.3.6 \ --hash=sha256:f3736c9dd3d1856f80cd031715b84ca75cda2bbb1ac802c3da26bfce590838d7 \ --hash=sha256:f5ed5fe84aee7f39db95c214a7421bf0499fbf500fec6d86a4e29bfc37971438 # via readme-renderer -numpy==2.4.6 \ - --hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \ - --hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \ - --hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \ - --hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \ - --hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \ - --hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \ - --hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \ - --hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \ - --hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \ - --hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \ - --hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \ - --hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \ - --hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \ - --hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \ - --hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \ - --hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \ - --hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \ - --hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \ - --hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \ - --hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \ - --hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \ - --hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \ - --hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \ - --hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \ - --hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \ - --hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \ - --hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \ - --hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \ - --hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \ - --hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \ - --hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \ - --hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \ - --hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \ - --hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \ - --hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \ - --hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \ - --hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \ - --hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \ - --hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \ - --hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \ - --hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \ - --hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \ - --hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \ - --hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \ - --hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \ - --hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \ - --hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \ - --hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \ - --hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \ - --hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \ - --hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \ - --hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \ - --hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \ - --hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \ - --hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \ - --hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \ - --hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \ - --hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \ - --hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \ - --hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \ - --hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \ - --hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \ - --hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \ - --hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \ - --hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \ - --hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \ - --hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \ - --hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \ - --hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \ - --hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \ - --hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \ - --hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20 - # via - # pandas - # pydeck - # streamlit packageurl-python==0.17.6 \ --hash=sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25 \ --hash=sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9 @@ -1069,161 +945,20 @@ packaging==26.2 \ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 # via - # altair # black # build # pip-audit # pip-requirements-parser # pytest # sphinx - # streamlit # twine # wheel -pandas==3.0.3 \ - --hash=sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c \ - --hash=sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2 \ - --hash=sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13 \ - --hash=sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04 \ - --hash=sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6 \ - --hash=sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824 \ - --hash=sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb \ - --hash=sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066 \ - --hash=sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e \ - --hash=sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76 \ - --hash=sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac \ - --hash=sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7 \ - --hash=sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5 \ - --hash=sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f \ - --hash=sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98 \ - --hash=sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d \ - --hash=sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1 \ - --hash=sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639 \ - --hash=sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2 \ - --hash=sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49 \ - --hash=sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a \ - --hash=sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028 \ - --hash=sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea \ - --hash=sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa \ - --hash=sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc \ - --hash=sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9 \ - --hash=sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf \ - --hash=sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c \ - --hash=sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27 \ - --hash=sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a \ - --hash=sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a \ - --hash=sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977 \ - --hash=sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a \ - --hash=sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938 \ - --hash=sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44 \ - --hash=sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4 \ - --hash=sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1 \ - --hash=sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb \ - --hash=sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f \ - --hash=sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d \ - --hash=sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc \ - --hash=sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870 \ - --hash=sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8 \ - --hash=sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085 \ - --hash=sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd \ - --hash=sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360 \ - --hash=sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c \ - --hash=sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09 - # via streamlit pathspec==1.1.1 \ --hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \ --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189 # via # black # mypy -pillow==12.3.0 \ - --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \ - --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \ - --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \ - --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \ - --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \ - --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \ - --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \ - --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \ - --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \ - --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \ - --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \ - --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \ - --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \ - --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \ - --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \ - --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \ - --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \ - --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \ - --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \ - --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \ - --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \ - --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \ - --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \ - --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \ - --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \ - --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \ - --hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \ - --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \ - --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \ - --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \ - --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \ - --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \ - --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \ - --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \ - --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \ - --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \ - --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \ - --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \ - --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \ - --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \ - --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \ - --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \ - --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \ - --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \ - --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \ - --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \ - --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \ - --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \ - --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \ - --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \ - --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \ - --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \ - --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \ - --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \ - --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \ - --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \ - --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \ - --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \ - --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \ - --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \ - --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \ - --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \ - --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \ - --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \ - --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \ - --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \ - --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \ - --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \ - --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \ - --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \ - --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \ - --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \ - --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \ - --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \ - --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \ - --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \ - --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \ - --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \ - --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \ - --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \ - --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \ - --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \ - --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \ - --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \ - --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \ - --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \ - --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 - # via streamlit pip-api==0.0.34 \ --hash=sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb \ --hash=sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625 @@ -1252,72 +987,10 @@ pluggy==1.6.0 \ # via # pytest # pytest-cov -protobuf==7.35.1 \ - --hash=sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799 \ - --hash=sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87 \ - --hash=sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6 \ - --hash=sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30 \ - --hash=sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9 \ - --hash=sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4 \ - --hash=sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4 \ - --hash=sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a - # via streamlit py-serializable==2.1.0 \ --hash=sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103 \ --hash=sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304 # via cyclonedx-python-lib -pyarrow==24.0.0 \ - --hash=sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba \ - --hash=sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68 \ - --hash=sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868 \ - --hash=sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495 \ - --hash=sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e \ - --hash=sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66 \ - --hash=sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275 \ - --hash=sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3 \ - --hash=sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838 \ - --hash=sha256:2392d954fcb920f42d230284b677605e4e2fbb11f2821e823e642abd67fbb491 \ - --hash=sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6 \ - --hash=sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826 \ - --hash=sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a \ - --hash=sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981 \ - --hash=sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e \ - --hash=sha256:3a577bd840ca83f646f0a625dbc571dba7044c43c2d1503afc378b570954345c \ - --hash=sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e \ - --hash=sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05 \ - --hash=sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b \ - --hash=sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb \ - --hash=sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136 \ - --hash=sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810 \ - --hash=sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37 \ - --hash=sha256:644a246325b8c69c595ad1dd4b463eba4b0cdb731370e4a86137d433208d6147 \ - --hash=sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0 \ - --hash=sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b \ - --hash=sha256:7c2b98645d576a0b9616892ead22b64a83a5f043c5e2ca15ebcefcb5b70c80cb \ - --hash=sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f \ - --hash=sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83 \ - --hash=sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca \ - --hash=sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931 \ - --hash=sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d \ - --hash=sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2 \ - --hash=sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795 \ - --hash=sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26 \ - --hash=sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74 \ - --hash=sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c \ - --hash=sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57 \ - --hash=sha256:bec9373df11544592b0ba7ec2af0e35059e5f0e7647c6183a854dedd193298f1 \ - --hash=sha256:c42ab9439498270139cc63e18847a02afe5c8b3ed9c931266533cfe378bd3591 \ - --hash=sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19 \ - --hash=sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42 \ - --hash=sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde \ - --hash=sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699 \ - --hash=sha256:e3268e43984d0b1a185c89b4cfff282a7ead12fc93f56cfd7088bdbcbe727041 \ - --hash=sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91 \ - --hash=sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76 \ - --hash=sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b \ - --hash=sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a \ - --hash=sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072 - # via streamlit pycodestyle==2.14.0 \ --hash=sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783 \ --hash=sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d @@ -1326,10 +999,6 @@ pycparser==3.0 \ --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 # via cffi -pydeck==0.9.3 \ - --hash=sha256:695775cbfe51f5fdffbd9735ba469987fdc5efc96bc40a0ee4808170509c78b2 \ - --hash=sha256:d8a47c11c81fb12d51b1feb42427ff4f0e13cb599e48931021b2cba98b6849a6 - # via streamlit pyflakes==3.4.0 \ --hash=sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58 \ --hash=sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f @@ -1362,16 +1031,10 @@ pytest-cov==7.1.0 \ --hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \ --hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678 # via -r requirements-dev.txt -python-dateutil==2.9.0.post0 \ - --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ - --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 - # via pandas python-multipart==0.0.32 \ --hash=sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e \ --hash=sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23 - # via - # -r requirements.txt - # streamlit + # via -r requirements.txt pytokens==0.4.1 \ --hash=sha256:0fc71786e629cef478cbf29d7ea1923299181d0699dbe7c3c0f4a583811d9fc1 \ --hash=sha256:11edda0942da80ff58c4408407616a310adecae1ddd22eef8c692fe266fa5009 \ @@ -1495,12 +1158,6 @@ readme-renderer==45.0 \ --hash=sha256:030a8fac74904f8fba11ad1bb6964e3f76e896dc7e5e71f16af190c9056696d1 \ --hash=sha256:3385ed220117104a2bceb4a9dac8c5fdf6d1f96890d7ea2a9c7174fd5c84091f # via twine -referencing==0.37.0 \ - --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ - --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 - # via - # jsonschema - # jsonschema-specifications requests==2.34.2 \ --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed @@ -1509,7 +1166,6 @@ requests==2.34.2 \ # pip-audit # requests-toolbelt # sphinx - # streamlit # twine requests-toolbelt==1.0.0 \ --hash=sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6 \ @@ -1530,153 +1186,11 @@ roman-numerals==4.1.0 \ --hash=sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2 \ --hash=sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7 # via sphinx -rpds-py==2026.5.1 \ - --hash=sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead \ - --hash=sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a \ - --hash=sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4 \ - --hash=sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256 \ - --hash=sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb \ - --hash=sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b \ - --hash=sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870 \ - --hash=sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc \ - --hash=sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08 \ - --hash=sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251 \ - --hash=sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473 \ - --hash=sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b \ - --hash=sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a \ - --hash=sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131 \ - --hash=sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9 \ - --hash=sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01 \ - --hash=sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba \ - --hash=sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad \ - --hash=sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db \ - --hash=sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d \ - --hash=sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0 \ - --hash=sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63 \ - --hash=sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee \ - --hash=sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7 \ - --hash=sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b \ - --hash=sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036 \ - --hash=sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb \ - --hash=sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16 \ - --hash=sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f \ - --hash=sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d \ - --hash=sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d \ - --hash=sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5 \ - --hash=sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78 \ - --hash=sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66 \ - --hash=sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972 \ - --hash=sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd \ - --hash=sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89 \ - --hash=sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732 \ - --hash=sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02 \ - --hash=sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef \ - --hash=sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a \ - --hash=sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c \ - --hash=sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723 \ - --hash=sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda \ - --hash=sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7 \ - --hash=sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca \ - --hash=sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02 \ - --hash=sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015 \ - --hash=sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1 \ - --hash=sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed \ - --hash=sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00 \ - --hash=sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a \ - --hash=sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195 \ - --hash=sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a \ - --hash=sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa \ - --hash=sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece \ - --hash=sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df \ - --hash=sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26 \ - --hash=sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa \ - --hash=sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842 \ - --hash=sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a \ - --hash=sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c \ - --hash=sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd \ - --hash=sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a \ - --hash=sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf \ - --hash=sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2 \ - --hash=sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f \ - --hash=sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf \ - --hash=sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049 \ - --hash=sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3 \ - --hash=sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964 \ - --hash=sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291 \ - --hash=sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14 \ - --hash=sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc \ - --hash=sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47 \ - --hash=sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5 \ - --hash=sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d \ - --hash=sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb \ - --hash=sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df \ - --hash=sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a \ - --hash=sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc \ - --hash=sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc \ - --hash=sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46 \ - --hash=sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb \ - --hash=sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2 \ - --hash=sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e \ - --hash=sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb \ - --hash=sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec \ - --hash=sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325 \ - --hash=sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600 \ - --hash=sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559 \ - --hash=sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41 \ - --hash=sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644 \ - --hash=sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b \ - --hash=sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162 \ - --hash=sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83 \ - --hash=sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038 \ - --hash=sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6 \ - --hash=sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b \ - --hash=sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3 \ - --hash=sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9 \ - --hash=sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34 \ - --hash=sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6 \ - --hash=sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb \ - --hash=sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa \ - --hash=sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6 \ - --hash=sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d \ - --hash=sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24 \ - --hash=sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838 \ - --hash=sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164 \ - --hash=sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97 \ - --hash=sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4 \ - --hash=sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2 \ - --hash=sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55 \ - --hash=sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3 \ - --hash=sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2 \ - --hash=sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358 \ - --hash=sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b \ - --hash=sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8 \ - --hash=sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0 \ - --hash=sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea \ - --hash=sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081 \ - --hash=sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d \ - --hash=sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1 \ - --hash=sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81 \ - --hash=sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3 \ - --hash=sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8 \ - --hash=sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1 \ - --hash=sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0 \ - --hash=sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd - # via - # jsonschema - # referencing secretstorage==3.5.0 ; sys_platform == "linux" \ --hash=sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137 # via # -r requirements-dev.txt # keyring -six==1.17.0 \ - --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ - --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 - # via python-dateutil -smmap==5.0.3 \ - --hash=sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c \ - --hash=sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f - # via gitdb snowballstemmer==3.1.1 \ --hash=sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752 \ --hash=sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260 @@ -1732,25 +1246,11 @@ sphinxcontrib-serializinghtml==2.0.0 \ starlette==1.3.1 \ --hash=sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0 \ --hash=sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6 - # via - # -r requirements.txt - # streamlit + # via -r requirements.txt stevedore==5.8.0 \ --hash=sha256:88eede9e66ca80e34085b9174e2327da2c61ac91f24f70e41c3ad76e4bb4872b \ --hash=sha256:b49867b32ca3016e94100e68dbf26e72aa7b8708d0a3f73c08aeb220370ac715 # via bandit -streamlit==1.59.2 \ - --hash=sha256:5014cb4f0064bd16c58fe334cb0d3c4b7e2be3d1ce4c02202c07114a33576dab \ - --hash=sha256:8fcb35df152868d33eec7ad2d89e276499a5351fb861eb1752b76476be7eb582 - # via -r requirements.txt -tenacity==9.1.4 \ - --hash=sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55 \ - --hash=sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a - # via streamlit -toml==0.10.2 \ - --hash=sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b \ - --hash=sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f - # via streamlit tomli==2.4.1 \ --hash=sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853 \ --hash=sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe \ @@ -1811,10 +1311,7 @@ twine==6.2.0 \ typing-extensions==4.16.0 \ --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 - # via - # altair - # mypy - # streamlit + # via mypy urllib3==2.7.0 \ --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 @@ -1825,152 +1322,7 @@ urllib3==2.7.0 \ uvicorn==0.51.0 \ --hash=sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b \ --hash=sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0 - # via - # -r requirements.txt - # streamlit -watchdog==6.0.0 \ - --hash=sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a \ - --hash=sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2 \ - --hash=sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f \ - --hash=sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c \ - --hash=sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c \ - --hash=sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c \ - --hash=sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0 \ - --hash=sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13 \ - --hash=sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134 \ - --hash=sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa \ - --hash=sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e \ - --hash=sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379 \ - --hash=sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a \ - --hash=sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11 \ - --hash=sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282 \ - --hash=sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b \ - --hash=sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f \ - --hash=sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c \ - --hash=sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112 \ - --hash=sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948 \ - --hash=sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881 \ - --hash=sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860 \ - --hash=sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3 \ - --hash=sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680 \ - --hash=sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26 \ - --hash=sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26 \ - --hash=sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e \ - --hash=sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8 \ - --hash=sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c \ - --hash=sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2 # via -r requirements.txt -websockets==16.1.1 \ - --hash=sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175 \ - --hash=sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a \ - --hash=sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d \ - --hash=sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985 \ - --hash=sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1 \ - --hash=sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573 \ - --hash=sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb \ - --hash=sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9 \ - --hash=sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87 \ - --hash=sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22 \ - --hash=sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328 \ - --hash=sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747 \ - --hash=sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab \ - --hash=sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499 \ - --hash=sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d \ - --hash=sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62 \ - --hash=sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512 \ - --hash=sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7 \ - --hash=sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf \ - --hash=sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa \ - --hash=sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57 \ - --hash=sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e \ - --hash=sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3 \ - --hash=sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0 \ - --hash=sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc \ - --hash=sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43 \ - --hash=sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe \ - --hash=sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31 \ - --hash=sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b \ - --hash=sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383 \ - --hash=sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217 \ - --hash=sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499 \ - --hash=sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8 \ - --hash=sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51 \ - --hash=sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509 \ - --hash=sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d \ - --hash=sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428 \ - --hash=sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead \ - --hash=sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc \ - --hash=sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1 \ - --hash=sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3 \ - --hash=sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0 \ - --hash=sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f \ - --hash=sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5 \ - --hash=sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731 \ - --hash=sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be \ - --hash=sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df \ - --hash=sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb \ - --hash=sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293 \ - --hash=sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e \ - --hash=sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7 \ - --hash=sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3 \ - --hash=sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3 \ - --hash=sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3 \ - --hash=sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a \ - --hash=sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9 \ - --hash=sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56 \ - --hash=sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562 \ - --hash=sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0 \ - --hash=sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15 \ - --hash=sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869 \ - --hash=sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7 \ - --hash=sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999 \ - --hash=sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01 \ - --hash=sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57 \ - --hash=sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838 \ - --hash=sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4 \ - --hash=sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1 \ - --hash=sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458 \ - --hash=sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3 \ - --hash=sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392 \ - --hash=sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3 \ - --hash=sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a \ - --hash=sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9 \ - --hash=sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785 \ - --hash=sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648 \ - --hash=sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49 \ - --hash=sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1 \ - --hash=sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7 \ - --hash=sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d \ - --hash=sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a \ - --hash=sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6 \ - --hash=sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231 \ - --hash=sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00 \ - --hash=sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac \ - --hash=sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea \ - --hash=sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c \ - --hash=sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81 \ - --hash=sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f \ - --hash=sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751 \ - --hash=sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68 \ - --hash=sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2 \ - --hash=sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c \ - --hash=sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57 \ - --hash=sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b \ - --hash=sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165 \ - --hash=sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737 \ - --hash=sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b \ - --hash=sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854 \ - --hash=sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87 \ - --hash=sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2 \ - --hash=sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847 \ - --hash=sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d \ - --hash=sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4 \ - --hash=sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8 \ - --hash=sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d \ - --hash=sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29 \ - --hash=sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051 \ - --hash=sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a - # via streamlit wheel==0.47.0 \ --hash=sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced \ --hash=sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3 diff --git a/requirements-lock.txt b/requirements-lock.txt index 97b38f0..4c3e154 100644 --- a/requirements-lock.txt +++ b/requirements-lock.txt @@ -4,34 +4,10 @@ # # pip-compile --generate-hashes --output-file=requirements-lock.txt requirements.txt # -altair==6.2.2 \ - --hash=sha256:94014f8ad8617c3cb163d1137359cd6db5ba134b9b46d93cfd8b609fd245a583 \ - --hash=sha256:a1ff9d9cfe81c75414641826312b9471780e19d39293ba0b012933f6b6cba0fe - # via streamlit anyio==4.14.2 \ --hash=sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494 \ --hash=sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f - # via - # starlette - # streamlit -attrs==26.1.0 \ - --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ - --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 - # via - # jsonschema - # referencing -blinker==1.9.0 \ - --hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \ - --hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc - # via streamlit -cachetools==7.1.4 \ - --hash=sha256:323dc4127934744db5b54eb4924482d7edafbf9554e820d1531c2e08c0e4ef54 \ - --hash=sha256:437f55a4e0c1b01a4f3077cc470e6991d47430970e36fbcb77e2be0df4fc1cd6 - # via streamlit -certifi==2026.6.17 \ - --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \ - --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db - # via requests + # via starlette cffi==2.1.0 \ --hash=sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc \ --hash=sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd \ @@ -134,107 +110,10 @@ cffi==2.1.0 \ --hash=sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da \ --hash=sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f # via cryptography -charset-normalizer==3.4.9 \ - --hash=sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380 \ - --hash=sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62 \ - --hash=sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c \ - --hash=sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226 \ - --hash=sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5 \ - --hash=sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833 \ - --hash=sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b \ - --hash=sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99 \ - --hash=sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501 \ - --hash=sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec \ - --hash=sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698 \ - --hash=sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4 \ - --hash=sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a \ - --hash=sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3 \ - --hash=sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2 \ - --hash=sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a \ - --hash=sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e \ - --hash=sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4 \ - --hash=sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419 \ - --hash=sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84 \ - --hash=sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da \ - --hash=sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519 \ - --hash=sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe \ - --hash=sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381 \ - --hash=sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29 \ - --hash=sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614 \ - --hash=sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0 \ - --hash=sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe \ - --hash=sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29 \ - --hash=sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0 \ - --hash=sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917 \ - --hash=sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9 \ - --hash=sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32 \ - --hash=sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94 \ - --hash=sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63 \ - --hash=sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd \ - --hash=sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198 \ - --hash=sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde \ - --hash=sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012 \ - --hash=sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1 \ - --hash=sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15 \ - --hash=sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b \ - --hash=sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993 \ - --hash=sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4 \ - --hash=sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5 \ - --hash=sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8 \ - --hash=sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35 \ - --hash=sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642 \ - --hash=sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2 \ - --hash=sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d \ - --hash=sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9 \ - --hash=sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c \ - --hash=sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33 \ - --hash=sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db \ - --hash=sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf \ - --hash=sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9 \ - --hash=sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee \ - --hash=sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84 \ - --hash=sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44 \ - --hash=sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f \ - --hash=sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9 \ - --hash=sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9 \ - --hash=sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177 \ - --hash=sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8 \ - --hash=sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b \ - --hash=sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39 \ - --hash=sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41 \ - --hash=sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0 \ - --hash=sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616 \ - --hash=sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d \ - --hash=sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba \ - --hash=sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2 \ - --hash=sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b \ - --hash=sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9 \ - --hash=sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b \ - --hash=sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209 \ - --hash=sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48 \ - --hash=sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046 \ - --hash=sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632 \ - --hash=sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a \ - --hash=sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2 \ - --hash=sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1 \ - --hash=sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b \ - --hash=sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990 \ - --hash=sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5 \ - --hash=sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b \ - --hash=sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9 \ - --hash=sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534 \ - --hash=sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81 \ - --hash=sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a \ - --hash=sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d \ - --hash=sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf \ - --hash=sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115 - # via requests click==8.4.2 \ --hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \ --hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76 - # via - # streamlit - # uvicorn + # via uvicorn cryptography==49.0.0 \ --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \ --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \ @@ -283,14 +162,6 @@ cryptography==49.0.0 \ --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \ --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b # via -r requirements.txt -gitdb==4.0.12 \ - --hash=sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571 \ - --hash=sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf - # via gitpython -gitpython==3.1.52 \ - --hash=sha256:79a36ee1f83523214a3f72d56cf1c4e490d577dc61af77e43dfe5862bd9da01a \ - --hash=sha256:de0a8ad86274c6e75ae8b37dd055ba68f19818c813108642263227b20775b48e - # via streamlit h11==0.16.0 \ --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 @@ -346,759 +217,28 @@ httptools==0.8.0 \ --hash=sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a \ --hash=sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0 \ --hash=sha256:fe2a4c95aeba2209434e7b31172da572846cae8ca0bf1e7013e61b99fbbf5e72 - # via - # -r requirements.txt - # streamlit + # via -r requirements.txt idna==3.18 \ --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 - # via - # anyio - # requests -itsdangerous==2.2.0 \ - --hash=sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef \ - --hash=sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173 - # via streamlit -jinja2==3.1.6 \ - --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ - --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 - # via - # altair - # pydeck -jsonschema==4.26.0 \ - --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ - --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce - # via altair -jsonschema-specifications==2025.9.1 \ - --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ - --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d - # via jsonschema -liboqs-python==0.15.0 \ - --hash=sha256:969b27c65c99f973f8c991472151331814c9f02642115fe4d6391bcdc33433a4 + # via anyio +liboqs-python==0.16.0 \ + --hash=sha256:50bb11a9767b88f68099cedef8de09f798f31eaba1aa18b483988bfcd50df440 \ + --hash=sha256:ee01445ccfbeab8dced956a642ce1eb5ba69438caba7ec291eea14083627d5c0 # via -r requirements.txt -markupsafe==3.0.3 \ - --hash=sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f \ - --hash=sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a \ - --hash=sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf \ - --hash=sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19 \ - --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \ - --hash=sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c \ - --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \ - --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \ - --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \ - --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \ - --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \ - --hash=sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26 \ - --hash=sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1 \ - --hash=sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce \ - --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \ - --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \ - --hash=sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695 \ - --hash=sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad \ - --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \ - --hash=sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c \ - --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \ - --hash=sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa \ - --hash=sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559 \ - --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \ - --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \ - --hash=sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758 \ - --hash=sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f \ - --hash=sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8 \ - --hash=sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d \ - --hash=sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c \ - --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \ - --hash=sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a \ - --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \ - --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \ - --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \ - --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \ - --hash=sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2 \ - --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \ - --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \ - --hash=sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50 \ - --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \ - --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \ - --hash=sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b \ - --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \ - --hash=sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115 \ - --hash=sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e \ - --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \ - --hash=sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f \ - --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \ - --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \ - --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \ - --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \ - --hash=sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b \ - --hash=sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a \ - --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \ - --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \ - --hash=sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d \ - --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \ - --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \ - --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \ - --hash=sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f \ - --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \ - --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \ - --hash=sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b \ - --hash=sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c \ - --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \ - --hash=sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8 \ - --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \ - --hash=sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6 \ - --hash=sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e \ - --hash=sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d \ - --hash=sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d \ - --hash=sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01 \ - --hash=sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7 \ - --hash=sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419 \ - --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \ - --hash=sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1 \ - --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \ - --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \ - --hash=sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42 \ - --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \ - --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \ - --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \ - --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \ - --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \ - --hash=sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591 \ - --hash=sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc \ - --hash=sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a \ - --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 - # via jinja2 -narwhals==2.24.0 \ - --hash=sha256:42fdedf44e5b2ca7505630d45b4ac3058f38d8485cba9fe1652ca23152df7489 \ - --hash=sha256:b5c0f684ccd9d7475b564111e319a4964abcf2baf79d3cf6b1003d06ac9b828d - # via altair -numpy==2.4.6 \ - --hash=sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1 \ - --hash=sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4 \ - --hash=sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f \ - --hash=sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079 \ - --hash=sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096 \ - --hash=sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47 \ - --hash=sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66 \ - --hash=sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d \ - --hash=sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1 \ - --hash=sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e \ - --hash=sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147 \ - --hash=sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd \ - --hash=sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75 \ - --hash=sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063 \ - --hash=sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73 \ - --hash=sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab \ - --hash=sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4 \ - --hash=sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41 \ - --hash=sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402 \ - --hash=sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698 \ - --hash=sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7 \ - --hash=sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8 \ - --hash=sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b \ - --hash=sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8 \ - --hash=sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0 \ - --hash=sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662 \ - --hash=sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91 \ - --hash=sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0 \ - --hash=sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f \ - --hash=sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3 \ - --hash=sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f \ - --hash=sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67 \ - --hash=sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6 \ - --hash=sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997 \ - --hash=sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b \ - --hash=sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e \ - --hash=sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538 \ - --hash=sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627 \ - --hash=sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93 \ - --hash=sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02 \ - --hash=sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853 \ - --hash=sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c \ - --hash=sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43 \ - --hash=sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd \ - --hash=sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8 \ - --hash=sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089 \ - --hash=sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778 \ - --hash=sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1 \ - --hash=sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb \ - --hash=sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261 \ - --hash=sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb \ - --hash=sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a \ - --hash=sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8 \ - --hash=sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359 \ - --hash=sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5 \ - --hash=sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7 \ - --hash=sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751 \ - --hash=sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8 \ - --hash=sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605 \ - --hash=sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e \ - --hash=sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45 \ - --hash=sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2 \ - --hash=sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895 \ - --hash=sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe \ - --hash=sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb \ - --hash=sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a \ - --hash=sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577 \ - --hash=sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d \ - --hash=sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a \ - --hash=sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda \ - --hash=sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6 \ - --hash=sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20 - # via - # pandas - # pydeck - # streamlit -packaging==26.2 \ - --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ - --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 - # via - # altair - # streamlit -pandas==3.0.3 \ - --hash=sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c \ - --hash=sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2 \ - --hash=sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13 \ - --hash=sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04 \ - --hash=sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6 \ - --hash=sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824 \ - --hash=sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb \ - --hash=sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066 \ - --hash=sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e \ - --hash=sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76 \ - --hash=sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac \ - --hash=sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7 \ - --hash=sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5 \ - --hash=sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f \ - --hash=sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98 \ - --hash=sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d \ - --hash=sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1 \ - --hash=sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639 \ - --hash=sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2 \ - --hash=sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49 \ - --hash=sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a \ - --hash=sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028 \ - --hash=sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea \ - --hash=sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa \ - --hash=sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc \ - --hash=sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9 \ - --hash=sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf \ - --hash=sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c \ - --hash=sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27 \ - --hash=sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a \ - --hash=sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a \ - --hash=sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977 \ - --hash=sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a \ - --hash=sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938 \ - --hash=sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44 \ - --hash=sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4 \ - --hash=sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1 \ - --hash=sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb \ - --hash=sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f \ - --hash=sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d \ - --hash=sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc \ - --hash=sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870 \ - --hash=sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8 \ - --hash=sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085 \ - --hash=sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd \ - --hash=sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360 \ - --hash=sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c \ - --hash=sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09 - # via streamlit -pillow==12.3.0 \ - --hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \ - --hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \ - --hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \ - --hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \ - --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \ - --hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \ - --hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \ - --hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \ - --hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \ - --hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \ - --hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \ - --hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \ - --hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \ - --hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \ - --hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \ - --hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \ - --hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \ - --hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \ - --hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \ - --hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \ - --hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \ - --hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \ - --hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \ - --hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \ - --hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \ - --hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \ - --hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \ - --hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \ - --hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \ - --hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \ - --hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \ - --hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \ - --hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \ - --hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \ - --hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \ - --hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \ - --hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \ - --hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \ - --hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \ - --hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \ - --hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \ - --hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \ - --hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \ - --hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \ - --hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \ - --hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \ - --hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \ - --hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \ - --hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \ - --hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \ - --hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \ - --hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \ - --hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \ - --hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \ - --hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \ - --hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \ - --hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \ - --hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \ - --hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \ - --hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \ - --hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \ - --hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \ - --hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \ - --hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \ - --hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \ - --hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \ - --hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \ - --hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \ - --hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \ - --hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \ - --hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \ - --hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \ - --hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \ - --hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \ - --hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \ - --hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \ - --hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \ - --hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \ - --hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \ - --hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \ - --hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \ - --hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \ - --hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \ - --hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \ - --hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \ - --hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \ - --hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7 - # via streamlit -protobuf==7.35.1 \ - --hash=sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799 \ - --hash=sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87 \ - --hash=sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6 \ - --hash=sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30 \ - --hash=sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9 \ - --hash=sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4 \ - --hash=sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4 \ - --hash=sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a - # via streamlit -pyarrow==24.0.0 \ - --hash=sha256:02b001b3ed4723caa44f6cd1af2d5c86aa2cf9971dacc2ffa55b21237713dfba \ - --hash=sha256:04920d6a71aabd08a0417709efce97d45ea8e6fb733d9ca9ecffb13c67839f68 \ - --hash=sha256:0b3537c00fb8d384f15ac1e79b6eb6db04a16514c8c1d22e59a9b95c8ba42868 \ - --hash=sha256:1183baeb14c5f587b1ec52831e665718ce632caab84b7cd6b85fd44f96114495 \ - --hash=sha256:14e31a3c9e35f1ab6356c6378f6f72830e6d2d5f1791df3774a7b097d18a6a1e \ - --hash=sha256:1617043b99bd33e5318ae18eb2919af09c71322ef1ca46566cdafc6e6712fb66 \ - --hash=sha256:1a4e45017efbf115032e4475ee876d525e0e36c742214fbe405332480ecd6275 \ - --hash=sha256:1b2fe7f9a5566401a0ef2571f197eb92358925c1f0c8dba305d6e43ea0871bb3 \ - --hash=sha256:1cc9057f0319e26333b357e17f3c2c022f1a83739b48a88b25bfd5fa2dc18838 \ - --hash=sha256:2392d954fcb920f42d230284b677605e4e2fbb11f2821e823e642abd67fbb491 \ - --hash=sha256:25ea65d868eb04015cd18e6df2fbe98f07e5bda2abefabcb88fce39a947716f6 \ - --hash=sha256:295f0a7f2e242dabd513737cf076007dc5b2d59237e3eca37b05c0c6446f3826 \ - --hash=sha256:2f16197705a230a78270cdd4ea8a1d57e86b2fdcbc34a1f6aebc72e65c986f9a \ - --hash=sha256:35405aecb474e683fb36af650618fd5340ee5471fc65a21b36076a18bbc6c981 \ - --hash=sha256:38be1808cdd068605b787e6ca9119b27eb275a0234e50212c3492331680c3b1e \ - --hash=sha256:3a577bd840ca83f646f0a625dbc571dba7044c43c2d1503afc378b570954345c \ - --hash=sha256:3b13dedfe76a0ad2d1d859b0811b53827a4e9d93a0bcb05cf59333ab4980cc7e \ - --hash=sha256:418e48ce50a45a6a6c73c454677203a9c75c966cb1e92ca3370959185f197a05 \ - --hash=sha256:55a3bc1e3df3b5567b7d27ef551b2283f0c68a5e86f1cd56abc569da4f31335b \ - --hash=sha256:6165461f55ef6314f026de6638d661188e3455d3ec49834556a0ebbdbace18bb \ - --hash=sha256:61a3d7eaa97a14768b542f3d284dc6400dd2470d9f080708b13cd46b6ae18136 \ - --hash=sha256:6233c9ed9ab9d1db47de57d9753256d9dcffbf42db341576099f0fd9f6bf4810 \ - --hash=sha256:641f795b361874ac9da5294f8f443dfdbee355cf2bd9e3b8d97aaac2306b9b37 \ - --hash=sha256:644a246325b8c69c595ad1dd4b463eba4b0cdb731370e4a86137d433208d6147 \ - --hash=sha256:6f066b179d68c413374294bc1735f68475457c933258df594443bb9d88ddc2a0 \ - --hash=sha256:7986f1fa71cee060ad00758bcc79d3a93bab8559bf978fab9e53472a2e25a17b \ - --hash=sha256:7c2b98645d576a0b9616892ead22b64a83a5f043c5e2ca15ebcefcb5b70c80cb \ - --hash=sha256:806f24b4085453c197a5078218d1ee08783ebbba271badd153d1ae22a3ee804f \ - --hash=sha256:85fe721a14dd823aca09127acbb06c3ca723efbd436c004f16bca601b04dcc83 \ - --hash=sha256:8adc8e6ce5fccf5dc707046ae4914fd537def529709cc0d285d37a7f9cd442ca \ - --hash=sha256:9700ebd9a51f5895ce75ff4ac4b3c47a7d4b42bc618be8e713e5d56bacf5f931 \ - --hash=sha256:9b18371ad2f44044b81a8d23bc2d8a9b6a6226dca775e8e16cfee640473d6c5d \ - --hash=sha256:a964266397740257f16f7bb2e4f08a0c81454004beab8ff59dd531b73610e9f2 \ - --hash=sha256:adbbedc55506cbdabb830890444fb856bfb0060c46c6f8026c6c2f2cf86ae795 \ - --hash=sha256:ae8a1145af31d903fa9bb166824d7abe9b4681a000b0159c9fb99c11bc11ad26 \ - --hash=sha256:b0e131f880cda8d04e076cee175a46fc0e8bc8b65c99c6c09dff6669335fde74 \ - --hash=sha256:b196eb3f931862af3fa84c2a253514d859c08e0d8fe020e07be12e75a5a9780c \ - --hash=sha256:b7d9a514e73bc42711e6a35aaccf3587c520024fe0a25d830a1a8a27c15f4f57 \ - --hash=sha256:bec9373df11544592b0ba7ec2af0e35059e5f0e7647c6183a854dedd193298f1 \ - --hash=sha256:c42ab9439498270139cc63e18847a02afe5c8b3ed9c931266533cfe378bd3591 \ - --hash=sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19 \ - --hash=sha256:d3e0b61e8efb24ed38898e5cdc5fffa9124be480008d401a1f8071500494ae42 \ - --hash=sha256:d7027eba1df3b2069e2e8d80f644fa0918b68c46432af3d088ddd390d063ecde \ - --hash=sha256:d8ddd2768da81d3ee08cfea9b597f4abb4e8e1dc8ae7e204b608d23a0d3ab699 \ - --hash=sha256:e3268e43984d0b1a185c89b4cfff282a7ead12fc93f56cfd7088bdbcbe727041 \ - --hash=sha256:e4505fc6583f7b05ab854934896bcac8253b04ac1171a77dfb73efef92076d91 \ - --hash=sha256:e56a1ffe9bf7b727432b89104cc0849c21582949dd7bdcb34f17b2001a351a76 \ - --hash=sha256:e6f1278ee4785b6db21229374a1c9e54ec7c549de5d1efc9630b6207de7e170b \ - --hash=sha256:f7616236ec1bc2b15bfdec22a71ab38851c86f8f05ff64f379e1278cf20c634a \ - --hash=sha256:fb24ac194bfc5e86839d7dcd52092ee31e5fe6733fe11f5e3b06ef0812b20072 - # via streamlit pycparser==3.0 \ --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 # via cffi -pydeck==0.9.3 \ - --hash=sha256:695775cbfe51f5fdffbd9735ba469987fdc5efc96bc40a0ee4808170509c78b2 \ - --hash=sha256:d8a47c11c81fb12d51b1feb42427ff4f0e13cb599e48931021b2cba98b6849a6 - # via streamlit -python-dateutil==2.9.0.post0 \ - --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \ - --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427 - # via pandas python-multipart==0.0.32 \ --hash=sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e \ --hash=sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23 - # via - # -r requirements.txt - # streamlit -referencing==0.37.0 \ - --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ - --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 - # via - # jsonschema - # jsonschema-specifications -requests==2.34.2 \ - --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \ - --hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed - # via streamlit -rpds-py==2026.5.1 \ - --hash=sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead \ - --hash=sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a \ - --hash=sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4 \ - --hash=sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256 \ - --hash=sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb \ - --hash=sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b \ - --hash=sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870 \ - --hash=sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc \ - --hash=sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08 \ - --hash=sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251 \ - --hash=sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473 \ - --hash=sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b \ - --hash=sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a \ - --hash=sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131 \ - --hash=sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9 \ - --hash=sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01 \ - --hash=sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba \ - --hash=sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad \ - --hash=sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db \ - --hash=sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d \ - --hash=sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0 \ - --hash=sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63 \ - --hash=sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee \ - --hash=sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7 \ - --hash=sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b \ - --hash=sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036 \ - --hash=sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb \ - --hash=sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16 \ - --hash=sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f \ - --hash=sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d \ - --hash=sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d \ - --hash=sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5 \ - --hash=sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78 \ - --hash=sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66 \ - --hash=sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972 \ - --hash=sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd \ - --hash=sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89 \ - --hash=sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732 \ - --hash=sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02 \ - --hash=sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef \ - --hash=sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a \ - --hash=sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c \ - --hash=sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723 \ - --hash=sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda \ - --hash=sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7 \ - --hash=sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca \ - --hash=sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02 \ - --hash=sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015 \ - --hash=sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1 \ - --hash=sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed \ - --hash=sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00 \ - --hash=sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a \ - --hash=sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195 \ - --hash=sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a \ - --hash=sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa \ - --hash=sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece \ - --hash=sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df \ - --hash=sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26 \ - --hash=sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa \ - --hash=sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842 \ - --hash=sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a \ - --hash=sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c \ - --hash=sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd \ - --hash=sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a \ - --hash=sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf \ - --hash=sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2 \ - --hash=sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f \ - --hash=sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf \ - --hash=sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049 \ - --hash=sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3 \ - --hash=sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964 \ - --hash=sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291 \ - --hash=sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14 \ - --hash=sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc \ - --hash=sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47 \ - --hash=sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5 \ - --hash=sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d \ - --hash=sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb \ - --hash=sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df \ - --hash=sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a \ - --hash=sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc \ - --hash=sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc \ - --hash=sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46 \ - --hash=sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb \ - --hash=sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2 \ - --hash=sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e \ - --hash=sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb \ - --hash=sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec \ - --hash=sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325 \ - --hash=sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600 \ - --hash=sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559 \ - --hash=sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41 \ - --hash=sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644 \ - --hash=sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b \ - --hash=sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162 \ - --hash=sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83 \ - --hash=sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038 \ - --hash=sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6 \ - --hash=sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b \ - --hash=sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3 \ - --hash=sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9 \ - --hash=sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34 \ - --hash=sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6 \ - --hash=sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb \ - --hash=sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa \ - --hash=sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6 \ - --hash=sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d \ - --hash=sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24 \ - --hash=sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838 \ - --hash=sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164 \ - --hash=sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97 \ - --hash=sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4 \ - --hash=sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2 \ - --hash=sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55 \ - --hash=sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3 \ - --hash=sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2 \ - --hash=sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358 \ - --hash=sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b \ - --hash=sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8 \ - --hash=sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0 \ - --hash=sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea \ - --hash=sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081 \ - --hash=sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d \ - --hash=sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1 \ - --hash=sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81 \ - --hash=sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3 \ - --hash=sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8 \ - --hash=sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1 \ - --hash=sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0 \ - --hash=sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd - # via - # jsonschema - # referencing -six==1.17.0 \ - --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \ - --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81 - # via python-dateutil -smmap==5.0.3 \ - --hash=sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c \ - --hash=sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f - # via gitdb + # via -r requirements.txt starlette==1.3.1 \ --hash=sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0 \ --hash=sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6 - # via - # -r requirements.txt - # streamlit -streamlit==1.59.2 \ - --hash=sha256:5014cb4f0064bd16c58fe334cb0d3c4b7e2be3d1ce4c02202c07114a33576dab \ - --hash=sha256:8fcb35df152868d33eec7ad2d89e276499a5351fb861eb1752b76476be7eb582 # via -r requirements.txt -tenacity==9.1.4 \ - --hash=sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55 \ - --hash=sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a - # via streamlit -toml==0.10.2 \ - --hash=sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b \ - --hash=sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f - # via streamlit -typing-extensions==4.16.0 \ - --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ - --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 - # via - # altair - # streamlit -urllib3==2.7.0 \ - --hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \ - --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 - # via requests uvicorn==0.51.0 \ --hash=sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b \ --hash=sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0 - # via - # -r requirements.txt - # streamlit -watchdog==6.0.0 \ - --hash=sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a \ - --hash=sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2 \ - --hash=sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f \ - --hash=sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c \ - --hash=sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c \ - --hash=sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c \ - --hash=sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0 \ - --hash=sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13 \ - --hash=sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134 \ - --hash=sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa \ - --hash=sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e \ - --hash=sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379 \ - --hash=sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a \ - --hash=sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11 \ - --hash=sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282 \ - --hash=sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b \ - --hash=sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f \ - --hash=sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c \ - --hash=sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112 \ - --hash=sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948 \ - --hash=sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881 \ - --hash=sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860 \ - --hash=sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3 \ - --hash=sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680 \ - --hash=sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26 \ - --hash=sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26 \ - --hash=sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e \ - --hash=sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8 \ - --hash=sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c \ - --hash=sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2 # via -r requirements.txt -websockets==16.1.1 \ - --hash=sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175 \ - --hash=sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a \ - --hash=sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d \ - --hash=sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985 \ - --hash=sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1 \ - --hash=sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573 \ - --hash=sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb \ - --hash=sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9 \ - --hash=sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87 \ - --hash=sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22 \ - --hash=sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328 \ - --hash=sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747 \ - --hash=sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab \ - --hash=sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499 \ - --hash=sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d \ - --hash=sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62 \ - --hash=sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512 \ - --hash=sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7 \ - --hash=sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf \ - --hash=sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa \ - --hash=sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57 \ - --hash=sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e \ - --hash=sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3 \ - --hash=sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0 \ - --hash=sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc \ - --hash=sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43 \ - --hash=sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe \ - --hash=sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31 \ - --hash=sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b \ - --hash=sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383 \ - --hash=sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217 \ - --hash=sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499 \ - --hash=sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8 \ - --hash=sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51 \ - --hash=sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509 \ - --hash=sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d \ - --hash=sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428 \ - --hash=sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead \ - --hash=sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc \ - --hash=sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1 \ - --hash=sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3 \ - --hash=sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0 \ - --hash=sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f \ - --hash=sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5 \ - --hash=sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731 \ - --hash=sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be \ - --hash=sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df \ - --hash=sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb \ - --hash=sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293 \ - --hash=sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e \ - --hash=sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7 \ - --hash=sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3 \ - --hash=sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3 \ - --hash=sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3 \ - --hash=sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a \ - --hash=sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9 \ - --hash=sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56 \ - --hash=sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562 \ - --hash=sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0 \ - --hash=sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15 \ - --hash=sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869 \ - --hash=sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7 \ - --hash=sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999 \ - --hash=sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01 \ - --hash=sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57 \ - --hash=sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838 \ - --hash=sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4 \ - --hash=sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1 \ - --hash=sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458 \ - --hash=sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3 \ - --hash=sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392 \ - --hash=sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3 \ - --hash=sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a \ - --hash=sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9 \ - --hash=sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785 \ - --hash=sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648 \ - --hash=sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49 \ - --hash=sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1 \ - --hash=sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7 \ - --hash=sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d \ - --hash=sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a \ - --hash=sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6 \ - --hash=sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231 \ - --hash=sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00 \ - --hash=sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac \ - --hash=sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea \ - --hash=sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c \ - --hash=sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81 \ - --hash=sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f \ - --hash=sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751 \ - --hash=sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68 \ - --hash=sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2 \ - --hash=sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c \ - --hash=sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57 \ - --hash=sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b \ - --hash=sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165 \ - --hash=sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737 \ - --hash=sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b \ - --hash=sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854 \ - --hash=sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87 \ - --hash=sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2 \ - --hash=sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847 \ - --hash=sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d \ - --hash=sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4 \ - --hash=sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8 \ - --hash=sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d \ - --hash=sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29 \ - --hash=sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051 \ - --hash=sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a - # via streamlit diff --git a/requirements.txt b/requirements.txt index b088bce..2a6ac84 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,8 @@ liboqs-python>=0.14.1,<1.0 cryptography>=42.0.0 -streamlit>=1.32.0 # Starlette <1.3.1 is affected by CVE-2026-54283. starlette>=1.3.1,<2 uvicorn>=0.49.0,<1 python-multipart>=0.0.32,<1 # Keep the locked runtime install portable on Ubuntu/Python 3.13. httptools>=0.8.0,<0.9 -# Streamlit pulls this on Linux; pin it explicitly so hashed locks stay portable. -watchdog>=2.1.5,<7 diff --git a/scripts/api_client.test.mjs b/scripts/api_client.test.mjs index 06afaa8..30af02e 100644 --- a/scripts/api_client.test.mjs +++ b/scripts/api_client.test.mjs @@ -1,22 +1,36 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import test from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { transformWithOxc } from "vite"; let moduleSequence = 0; async function loadApiModule() { - const source = await readFile(new URL("../web/src/api.ts", import.meta.url), "utf8"); - const compiled = ( - await transformWithOxc(source, "api.ts", { + const tmpDirectory = new URL("../tmp/api-client-tests/", import.meta.url); + await mkdir(tmpDirectory, { recursive: true }); + + const contractsSource = await readFile(new URL("../web/src/api/contracts.ts", import.meta.url), "utf8"); + const contracts = ( + await transformWithOxc(contractsSource, "contracts.ts", { + lang: "ts", + sourcemap: false + }) + ).code; + await writeFile(new URL("contracts.mjs", tmpDirectory), contracts); + + const clientSource = await readFile(new URL("../web/src/api/client.ts", import.meta.url), "utf8"); + const client = ( + await transformWithOxc(clientSource.replace("./contracts", "./contracts.mjs"), "client.ts", { lang: "ts", sourcemap: false }) ).code; - const encoded = Buffer.from(compiled).toString("base64"); moduleSequence += 1; - return import(`data:text/javascript;base64,${encoded}#${moduleSequence}`); + const clientPath = new URL(`client-${moduleSequence}.mjs`, tmpDirectory); + await writeFile(clientPath, client); + return import(`${pathToFileURL(fileURLToPath(clientPath)).href}#${moduleSequence}`); } function healthPayload(apiToken) { @@ -24,6 +38,12 @@ function healthPayload(apiToken) { ok: true, backendReady: true, backendMessage: "Post-quantum backend ready.", + capabilities: { + inspect: { available: true, reason: "" }, + generate: { available: true, reason: "" }, + encrypt: { available: true, reason: "" }, + decrypt: { available: true, reason: "" } + }, formatVersion: 4, kem: "ML-KEM-768+X25519-v2", kemComponent: "ML-KEM-768", @@ -128,3 +148,43 @@ test("an unrelated authorization rejection is not retried", async (t) => { { url: "/api/keys/generate", token: "current-token" } ]); }); + +test("sensitive operation signals reach each state-changing fetch", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + const calls = []; + globalThis.fetch = async (input, init = {}) => { + const url = String(input); + calls.push({ url, signal: init.signal }); + if (url === "/api/health") return jsonResponse(healthPayload("current-token")); + if (url === "/api/keys/generate") return jsonResponse(generatedKeysPayload()); + if (url === "/api/files/encrypt" || url === "/api/files/decrypt") { + return new Response(new Blob([url]), { + status: 200, + headers: { "Content-Disposition": 'attachment; filename="result.bin"' } + }); + } + throw new Error(`Unexpected request: ${url}`); + }; + + const api = await loadApiModule(); + const controller = new AbortController(); + const file = new Blob(["file"]); + const key = new Blob(["key"]); + + await api.generateKeys("correct horse battery staple", controller.signal); + await api.encryptFile(file, key, "encrypted.pqc", controller.signal); + await api.decryptFile(file, key, "correct horse battery staple", "plain.txt", controller.signal); + + assert.deepEqual( + calls.filter(({ url }) => url !== "/api/health").map(({ url, signal }) => ({ url, signal })), + [ + { url: "/api/keys/generate", signal: controller.signal }, + { url: "/api/files/encrypt", signal: controller.signal }, + { url: "/api/files/decrypt", signal: controller.signal } + ] + ); +}); diff --git a/scripts/ui_native_smoke.mjs b/scripts/ui_native_smoke.mjs new file mode 100644 index 0000000..246d38f --- /dev/null +++ b/scripts/ui_native_smoke.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { chromium } from "playwright"; + +const baseUrl = process.env.UI_NATIVE_URL ?? "http://127.0.0.1:4000/"; +const password = "correct horse battery staple"; +const inputBytes = Buffer.from("native browser round trip"); + +function assertReadyHealth(health) { + const requiredCapabilities = ["generate", "encrypt", "decrypt"]; + const isReady = Boolean( + health?.ok && + health.backendReady && + requiredCapabilities.every((capability) => health.capabilities?.[capability]?.available) + ); + + assert.equal(isReady, true, "The native local engine is not ready for key generation, encryption, and decryption."); +} + +async function saveDownload(download, destination) { + await download.saveAs(destination); + return destination; +} + +async function downloadFromButton(page, name, destination) { + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name }).click(); + return saveDownload(await downloadPromise, destination); +} + +async function run() { + let temporaryDirectory; + let browser; + + try { + temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), "quantum-encryptor-native-ui-")); + const inputPath = path.join(temporaryDirectory, "input.txt"); + const publicKeyPath = path.join(temporaryDirectory, "public.pem"); + const privateKeyPath = path.join(temporaryDirectory, "private.pem"); + const encryptedPath = path.join(temporaryDirectory, "input_encrypted.pqc"); + const decryptedPath = path.join(temporaryDirectory, "input_decrypted.txt"); + await writeFile(inputPath, inputBytes); + + browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await context.newPage(); + + await page.goto(baseUrl, { waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Encrypt a file" }).waitFor(); + + const health = await page.evaluate(async () => { + const response = await fetch("/api/health", { credentials: "same-origin" }); + if (!response.ok) return null; + return response.json(); + }); + assertReadyHealth(health); + await page.getByText("Ready", { exact: true }).first().waitFor({ state: "visible" }); + + await page.getByRole("button", { name: "Generate keys" }).click(); + await page.getByRole("heading", { name: "Generate keys" }).waitFor(); + await page.getByLabel("Private key password", { exact: true }).fill(password); + await page.getByLabel("Confirm private key password").fill(password); + await page.getByRole("button", { name: "Generate key pair" }).click(); + await page.getByText("Key pair generated", { exact: true }).waitFor({ state: "visible" }); + await downloadFromButton(page, "Download public key", publicKeyPath); + await downloadFromButton(page, "Download encrypted private key", privateKeyPath); + await page.getByRole("button", { name: "Clear generated keys" }).click(); + assert.equal(await page.getByRole("button", { name: "Download public key" }).count(), 0); + assert.equal(await page.getByRole("button", { name: "Download encrypted private key" }).count(), 0); + + await page.getByRole("button", { name: "Encrypt" }).first().click(); + await page.getByRole("heading", { name: "Encrypt a file" }).waitFor(); + await page.getByLabel("File to encrypt").setInputFiles(inputPath); + await page.getByLabel("Recipient public key").setInputFiles(publicKeyPath); + await page.getByText("Compatible public key", { exact: true }).waitFor({ state: "visible" }); + const encryptedDownload = page.waitForEvent("download"); + await page.getByRole("button", { name: "Encrypt file" }).click(); + await saveDownload(await encryptedDownload, encryptedPath); + await page.getByText("File encrypted", { exact: true }).waitFor({ state: "visible" }); + + await page.getByRole("button", { name: "Decrypt" }).first().click(); + await page.getByRole("heading", { name: "Decrypt a file" }).waitFor(); + await page.getByLabel("Encrypted file").setInputFiles(encryptedPath); + await page.getByLabel("Private key", { exact: true }).setInputFiles(privateKeyPath); + await page + .getByText("Supported encrypted private key; match not yet verified", { exact: true }) + .waitFor({ state: "visible" }); + await page.getByLabel("Private key password", { exact: true }).fill(password); + const decryptedDownload = page.waitForEvent("download"); + await page.getByRole("button", { name: "Decrypt file" }).click(); + await saveDownload(await decryptedDownload, decryptedPath); + await page.getByText("File decrypted", { exact: true }).waitFor({ state: "visible" }); + + assert.deepEqual(await readFile(decryptedPath), inputBytes, "The native browser round trip returned different bytes."); + console.log("Native browser encryption round trip passed."); + } finally { + try { + await browser?.close(); + } finally { + if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true }); + } + } +} + +try { + await run(); +} catch { + console.error("Native browser encryption round trip failed."); + process.exitCode = 1; +} diff --git a/scripts/ui_smoke.mjs b/scripts/ui_smoke.mjs index dad1865..2255ab8 100644 --- a/scripts/ui_smoke.mjs +++ b/scripts/ui_smoke.mjs @@ -1,50 +1,381 @@ +import assert from "node:assert/strict"; import { mkdir } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import AxeBuilder from "@axe-core/playwright"; import { chromium } from "playwright"; const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const outDir = path.join(rootDir, "tmp", "ui-smoke"); const baseUrl = process.env.UI_SMOKE_URL ?? "http://127.0.0.1:4000/"; +const localToken = "ui-smoke-local-token"; + +const publicInspection = { + ok: true, + keyInfo: { kem: "ML-KEM-768+X25519-v2", key_type: "public" }, + display: { + "Key Type": "Public key", + "Hybrid suite": "ML-KEM-768+X25519-v2" + } +}; + +const outdatedPublicInspection = { + ok: true, + keyInfo: { kem: "ML-KEM-768+X25519", key_type: "public" }, + display: { + "Key Type": "Public key", + "Hybrid suite": "ML-KEM-768+X25519" + } +}; + +const privateInspection = { + ok: true, + keyInfo: { + kem: "ML-KEM-768+X25519-v2", + key_type: "private", + private_key_encrypted: true, + private_key_format_version: 2, + private_key_kdf: "scrypt" + }, + display: { + "Key Type": "Encrypted private key", + "Key format": "2", + "Key KDF": "scrypt", + "Hybrid suite": "ML-KEM-768+X25519-v2" + } +}; + +function health({ limited = false, strictFileLimit = false } = {}) { + const available = { available: true, reason: "" }; + return { + ok: true, + backendReady: !limited, + backendMessage: limited + ? "The ML-KEM backend is not ready, so new keys and ciphertexts cannot be created. Compatible encrypted archives may still be decryptable." + : "Post-quantum backend ready.", + capabilities: limited + ? { + inspect: available, + generate: { available: false, reason: "Key generation is unavailable in this local engine." }, + encrypt: { available: false, reason: "Encryption is unavailable in this local engine." }, + decrypt: available + } + : { inspect: available, generate: available, encrypt: available, decrypt: available }, + formatVersion: 4, + kem: "ML-KEM-768+X25519-v2", + kemComponent: "ML-KEM-768", + configuredKem: "ML-KEM-768", + dem: "AES-256-GCM", + maxFileBytes: strictFileLimit ? 3 : 104857600, + maxEncryptedFileBytes: 105906314, + maxPemBytes: 131072, + apiToken: localToken, + passwordPolicy: { minChars: 16, minUniqueChars: 5 } + }; +} + +function json(route, payload, status = 200) { + return route.fulfill({ + status, + contentType: "application/json", + body: JSON.stringify(payload) + }); +} + +function errorPayload(errorCode, message) { + return { ok: false, error_code: errorCode, message }; +} + +function file(name, contents, mimeType = "application/octet-stream") { + return { name, mimeType, buffer: Buffer.from(contents) }; +} + +function activeElementLabel() { + const element = document.activeElement; + if (!(element instanceof HTMLElement)) return ""; + const associatedLabel = "labels" in element && element.labels?.[0]?.textContent?.trim(); + return element.getAttribute("aria-label") || associatedLabel || element.textContent?.trim() || element.id; +} + +async function collectTabOrder(page, limit = 40) { + await page.evaluate(() => (document.activeElement instanceof HTMLElement ? document.activeElement.blur() : undefined)); + const order = []; + for (let index = 0; index < limit; index += 1) { + await page.keyboard.press("Tab"); + order.push(await page.evaluate(activeElementLabel)); + } + return order; +} + +function assertLogicalTabOrder(order, labels) { + let lastIndex = -1; + for (const label of labels) { + const nextIndex = order.findIndex((value, index) => index > lastIndex && value === label); + assert.notEqual(nextIndex, -1, `Tab did not reach ${label}: ${JSON.stringify(order)}`); + lastIndex = nextIndex; + } +} + +async function assertNoHorizontalOverflow(page) { + for (const viewport of [ + { width: 390, height: 844 }, + { width: 768, height: 900 }, + { width: 1024, height: 768 }, + { width: 1440, height: 900 } + ]) { + await page.setViewportSize(viewport); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth); + assert.equal(overflow, false, `horizontal overflow at ${viewport.width}`); + } +} + +async function assertAxe(page, label) { + const scan = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + assert.deepEqual(scan.violations, [], `${label} accessibility violations: ${JSON.stringify(scan.violations, null, 2)}`); +} await mkdir(outDir, { recursive: true }); +let limitedHealth = false; +let strictFileLimit = false; +const unexpectedApiRequests = []; +const apiRequests = []; +let staleInspectionRoute = null; +let captureStaleInspectionRoute; +const staleInspectionRouteCaptured = new Promise((resolve) => { + captureStaleInspectionRoute = resolve; +}); const browser = await chromium.launch({ headless: true }); try { - const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + const context = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await context.newPage(); + + await page.route("**/api/**", async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + const method = request.method(); + const payload = request.postDataBuffer()?.toString("utf8") ?? ""; + apiRequests.push(`${method} ${pathname}`); + + if (method === "GET" && pathname === "/api/health") { + return json(route, health({ limited: limitedHealth, strictFileLimit })); + } + if (method === "POST" && pathname === "/api/keys/inspect") { + if (payload.includes("stale-first.pem")) { + staleInspectionRoute = route; + captureStaleInspectionRoute(); + return; + } + if (payload.includes("outdated-public.pem")) return json(route, outdatedPublicInspection); + if (payload.includes("wrong-key-type.pem")) return json(route, privateInspection); + return json(route, payload.includes("private.pem") ? privateInspection : publicInspection); + } + if (method === "POST" && pathname === "/api/keys/generate") { + return json(route, { + ok: true, + kem: "ML-KEM-768+X25519-v2", + publicPem: "PUBLIC-PEM", + privatePem: "PRIVATE-PEM", + publicFilename: "quantum_public.pem", + privateFilename: "quantum_private_encrypted.pem" + }); + } + if (method === "POST" && pathname === "/api/files/encrypt") { + if (payload.includes("unexpected-backend-error.txt")) { + return json(route, errorPayload("unexpected_backend_error", "Raw fixture detail: /not-for-the-user"), 500); + } + return route.fulfill({ + status: 200, + contentType: "application/octet-stream", + headers: { "Content-Disposition": 'attachment; filename="report_encrypted.pqc"' }, + body: "ciphertext" + }); + } + if (method === "POST" && pathname === "/api/files/decrypt") { + if (payload.includes("tampered_encrypted.pqc")) { + return json(route, errorPayload("decryption_failed", "Ciphertext authentication failed."), 422); + } + return route.fulfill({ + status: 200, + contentType: "application/octet-stream", + headers: { "Content-Disposition": 'attachment; filename="report.txt"' }, + body: "plaintext" + }); + } + unexpectedApiRequests.push(`${method} ${pathname}`); + return json(route, errorPayload("unexpected_ui_smoke_api_request", "Unexpected local test API request."), 500); + }); + await page.goto(baseUrl, { waitUntil: "networkidle" }); - const states = { - encryptDisabled: await page.getByRole("button", { name: "Encrypt File" }).last().isDisabled() - }; + await page.getByRole("heading", { name: "Encrypt a file" }).waitFor(); + assert.equal(await page.getByRole("main").count(), 1); + assert.equal(await page.getByRole("navigation", { name: "Workflows" }).count(), 1); + assert.equal(await page.getByRole("button", { name: "Encrypt" }).first().getAttribute("aria-current"), "page"); + await expectText(page, "Ready"); - await page.getByRole("button", { name: "Decrypt File" }).click(); - states.decryptVisible = await page.getByRole("heading", { name: "Decrypt File" }).isVisible(); - states.decryptDisabled = await page.getByRole("button", { name: "Decrypt File" }).last().isDisabled(); + strictFileLimit = true; + await page.reload({ waitUntil: "networkidle" }); + const encryptRequestsBeforeOversizedFile = apiRequests.filter((request) => request === "POST /api/files/encrypt").length; + await page.getByLabel("File to encrypt").setInputFiles(file("oversized.txt", "four", "text/plain")); + await expectText(page, "This file exceeds the 3 byte limit."); + assert.equal(await page.getByRole("button", { name: "Encrypt file" }).isDisabled(), true); + assert.equal( + apiRequests.filter((request) => request === "POST /api/files/encrypt").length, + encryptRequestsBeforeOversizedFile, + "oversized files must not trigger encryption requests" + ); - await page.getByRole("button", { name: "Generate Keys" }).click(); - states.generateVisible = await page.getByRole("heading", { name: "Generate Keys" }).isVisible(); - states.generateDisabled = await page.getByRole("button", { name: /Generate .* Key Pair/ }).isDisabled(); + strictFileLimit = false; + await page.reload({ waitUntil: "networkidle" }); - await page.getByRole("button", { name: "Inspect Key" }).click(); - states.inspectVisible = await page.getByRole("heading", { name: "Inspect Key" }).isVisible(); + await page.getByLabel("File to encrypt").setInputFiles(file("report.txt", "local report", "text/plain")); + await page.getByLabel("Recipient public key").setInputFiles(file("public.pem", "PUBLIC-PEM", "application/x-pem-file")); + await expectText(page, "Compatible public key"); + await page.getByRole("button", { name: "Encrypt file" }).waitFor({ state: "visible" }); + assert.equal(await page.getByRole("button", { name: "Encrypt file" }).isEnabled(), true); + assert.equal(await page.getByLabel("Output filename").inputValue(), "report_encrypted.pqc"); - await page.setViewportSize({ width: 1280, height: 720 }); - await page.getByRole("button", { name: "Encrypt File" }).click(); + const desktopTabOrder = await collectTabOrder(page); + assertLogicalTabOrder(desktopTabOrder, ["Encrypt", "File to encrypt", "Recipient public key", "Output filename", "Encrypt file", "Technical details"]); + assert.equal(await page.getByText("Technical details").first().evaluate((element) => element.parentElement?.open), false); + await page.getByText("Technical details").first().click(); + assert.equal(await page.getByText("Technical details").first().evaluate((element) => element.parentElement?.open), true); + await assertNoHorizontalOverflow(page); + await page.setViewportSize({ width: 1440, height: 900 }); + await assertAxe(page, "ready Encrypt screen"); await page.screenshot({ path: path.join(outDir, "quantum-encryptor-web.png"), fullPage: true }); + await page.getByRole("button", { name: "Encrypt file" }).click(); + await expectText(page, "File encrypted"); + await expectText(page, "Saved report_encrypted.pqc."); + + await page.getByLabel("Recipient public key").setInputFiles(file("outdated-public.pem", "OUTDATED", "application/x-pem-file")); + await expectText(page, "This public key uses ML-KEM-768+X25519; encryption requires ML-KEM-768+X25519-v2."); + assert.equal(await page.getByRole("button", { name: "Encrypt file" }).isDisabled(), true); + + await page.getByLabel("Recipient public key").setInputFiles(file("wrong-key-type.pem", "PRIVATE-PEM", "application/x-pem-file")); + await expectText(page, "Private key — not valid for encryption"); + assert.equal(await page.getByRole("button", { name: "Encrypt file" }).isDisabled(), true); + + await page.getByLabel("Recipient public key").setInputFiles(file("public.pem", "PUBLIC-PEM", "application/x-pem-file")); + await expectText(page, "Compatible public key"); + await page.getByLabel("File to encrypt").setInputFiles(file("unexpected-backend-error.txt", "safe local fixture", "text/plain")); + await page.getByRole("button", { name: "Encrypt file" }).click(); + await expectText(page, "Could not encrypt this file. Confirm the recipient key and try again."); + assert.equal(await page.getByText("Raw fixture detail: /not-for-the-user").count(), 0); + + await page.getByRole("button", { name: "Inspect key" }).first().click(); + await page.getByRole("heading", { name: "Inspect a key" }).waitFor(); + assert.equal(await page.getByRole("button", { name: "Inspect key" }).first().getAttribute("aria-current"), "page"); + await page.getByLabel("Key file").setInputFiles(file("public.pem", "PUBLIC-PEM", "application/x-pem-file")); + await expectText(page, "Public key"); + await page.getByText("Technical details").first().click(); + await expectText(page, "ML-KEM-768+X25519-v2"); + await page.getByLabel("Key file").setInputFiles(file("private.pem", "PRIVATE-PEM", "application/x-pem-file")); + await expectText(page, "Encrypted private key"); + await page.getByText("Technical details").first().click(); + await expectText(page, "scrypt"); + await page.getByLabel("Key file").setInputFiles(file("stale-first.pem", "STALE", "application/x-pem-file")); + await staleInspectionRouteCaptured; + await page.getByLabel("Key file").setInputFiles(file("public.pem", "PUBLIC-PEM", "application/x-pem-file")); + await expectText(page, "Public key"); + await staleInspectionRoute?.abort("failed"); + assert.equal(await page.getByText("Public key").count() > 0, true, "replaced inspection must remain current after cancellation"); + + await page.getByRole("button", { name: "Generate keys" }).click(); + await page.getByRole("heading", { name: "Generate keys" }).waitFor(); + await page.getByLabel("Private key password", { exact: true }).fill("correct-horse-battery-staple"); + await page.getByLabel("Confirm private key password").fill("correct-horse-battery-staple"); + await page.getByRole("button", { name: "Generate key pair" }).click(); + await expectText(page, "Key pair generated"); + await expectText(page, "quantum_public.pem"); + await expectText(page, "quantum_private_encrypted.pem"); + await page.getByRole("button", { name: "Clear generated keys" }).click(); + assert.equal(await page.getByRole("button", { name: "Download public key" }).count(), 0); + assert.equal(await page.getByRole("button", { name: "Download encrypted private key" }).count(), 0); + + await page.getByRole("button", { name: "Decrypt" }).first().click(); + await page.getByRole("heading", { name: "Decrypt a file" }).waitFor(); + await page.getByLabel("Encrypted file").setInputFiles(file("report_encrypted.pqc", "ciphertext")); + await page.getByLabel("Private key", { exact: true }).setInputFiles(file("private.pem", "PRIVATE-PEM", "application/x-pem-file")); + await expectText(page, "Supported encrypted private key; match not yet verified"); + await page.getByLabel("Private key password", { exact: true }).fill("correct-horse-battery-staple"); + assert.equal(await page.getByLabel("Output filename").inputValue(), "report"); + await page.getByRole("button", { name: "Decrypt file" }).click(); + await expectText(page, "File decrypted"); + await expectText(page, "Saved report.txt."); + + await page.getByLabel("Encrypted file").setInputFiles(file("tampered_encrypted.pqc", "tampered")); + await page.getByLabel("Private key password", { exact: true }).fill("correct-horse-battery-staple"); + await page.getByRole("button", { name: "Decrypt file" }).click(); + await expectText(page, "Decryption failed"); + await expectText(page, "The file could not be authenticated. Check the encrypted file, private key, and password."); + assert.equal(await page.getByText("Ciphertext authentication failed.").count(), 0); + await page.setViewportSize({ width: 390, height: 844 }); - await page.getByRole("button", { name: "Inspect Key" }).click(); + await page.getByRole("link", { name: "Inspect key" }).click(); + await page.getByRole("heading", { name: "Inspect a key" }).waitFor(); + assert.equal(await page.getByRole("link", { name: "Inspect key" }).getAttribute("aria-current"), "page"); + await page.getByLabel("Key file").setInputFiles(file("public.pem", "PUBLIC-PEM", "application/x-pem-file")); + await expectText(page, "Public key"); + await assertAxe(page, "mobile Inspect screen"); await page.screenshot({ path: path.join(outDir, "quantum-encryptor-mobile.png"), fullPage: true }); - if (!states.encryptDisabled || !states.decryptVisible || !states.decryptDisabled) { - throw new Error(`Unexpected file workflow state: ${JSON.stringify(states)}`); - } - if (!states.generateVisible || !states.generateDisabled || !states.inspectVisible) { - throw new Error(`Unexpected key workflow state: ${JSON.stringify(states)}`); - } + await page.emulateMedia({ reducedMotion: "reduce" }); + const reducedMotionDuration = await page.locator(".app-mobile-navigation a").first().evaluate( + (element) => getComputedStyle(element).transitionDuration + ); + assert.ok( + reducedMotionDuration.split(", ").every((duration) => Number.parseFloat(duration) <= 0.01), + `reduced-motion transition duration is too long: ${reducedMotionDuration}` + ); + + limitedHealth = true; + await page.setViewportSize({ width: 1440, height: 900 }); + await page.reload({ waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Encrypt a file" }).waitFor(); + await expectText(page, "Limited"); + await expectText(page, "Encryption is unavailable in this local engine."); + assert.equal(await page.getByLabel("File to encrypt").count(), 0); + await page.getByText("Capability details").first().click(); + await expectText(page, "Key generation is unavailable in this local engine."); + await expectText(page, "Encryption is unavailable in this local engine."); + + await page.getByRole("button", { name: "Inspect key" }).first().click(); + await page.getByRole("heading", { name: "Inspect a key" }).waitFor(); + assert.equal(await page.getByLabel("Key file").count(), 1); - console.log(JSON.stringify(states, null, 2)); + await page.getByRole("button", { name: "Generate keys" }).click(); + await page.getByRole("heading", { name: "Generate keys" }).waitFor(); + await expectText(page, "Key generation is unavailable in this local engine."); + assert.equal(await page.getByLabel("Private key password", { exact: true }).count(), 0); + assert.equal(await page.getByRole("button", { name: "Generate key pair" }).count(), 0); + + await page.getByRole("button", { name: "Encrypt" }).first().click(); + await page.getByRole("heading", { name: "Encrypt a file" }).waitFor(); + await expectText(page, "Encryption is unavailable in this local engine."); + assert.equal(await page.getByLabel("File to encrypt").count(), 0); + + await page.getByRole("button", { name: "Decrypt" }).first().click(); + await page.getByRole("heading", { name: "Decrypt a file" }).waitFor(); + assert.equal(await page.getByRole("button", { name: "Decrypt file" }).isDisabled(), true); + await expectText(page, "Choose an encrypted file."); + + assert.deepEqual(unexpectedApiRequests, [], `Unexpected API requests: ${unexpectedApiRequests.join(", ")}`); + + console.log(JSON.stringify({ + health: ["all capabilities", "inspect and decrypt"], + workflows: ["inspect", "generate", "encrypt", "decrypt", "decryption_failed"], + viewports: [390, 768, 1024, 1440], + screenshots: ["quantum-encryptor-web.png", "quantum-encryptor-mobile.png"] + }, null, 2)); } finally { await browser.close(); } + +async function expectText(page, text) { + await page.getByText(text, { exact: true }).filter({ visible: true }).first().waitFor({ state: "visible" }); +} diff --git a/start.sh b/start.sh index eb0be2a..b66ec1f 100755 --- a/start.sh +++ b/start.sh @@ -22,12 +22,6 @@ if [ -z "$PYTHON_BIN" ]; then exit 127 fi -if [ "${LEGACY_STREAMLIT:-0}" = "1" ]; then - exec "$PYTHON_BIN" -m streamlit run pqc_app.py \ - --server.address 127.0.0.1 \ - --server.port "$APP_PORT" -fi - if [ "${SKIP_WEB_BUILD:-0}" != "1" ]; then if [ ! -x "node_modules/.bin/vite" ]; then echo "Frontend dependencies are missing. Run npm install before ./start.sh." >&2 diff --git a/tests/test_api_app.py b/tests/test_api_app.py index 726476a..fd84fc2 100644 --- a/tests/test_api_app.py +++ b/tests/test_api_app.py @@ -47,6 +47,31 @@ def test_health_payload_reports_ready_backend(monkeypatch): assert payload["kemComponent"] == "ML-KEM-768" +def test_health_payload_reports_partial_capabilities(monkeypatch): + def missing_current_backend(_kem): + raise core.CryptoDependencyError("private backend detail") + + monkeypatch.setattr(core, "resolve_kem_algorithm", missing_current_backend) + monkeypatch.setattr(core, "available_decryption_kem_algorithms", lambda: ("Kyber768",)) + + payload = api_app._health_payload() + + assert payload["backendReady"] is False + assert payload["capabilities"] == { + "inspect": {"available": True, "reason": ""}, + "generate": { + "available": False, + "reason": "ML-KEM-768 is unavailable for new key generation.", + }, + "encrypt": { + "available": False, + "reason": "ML-KEM-768 is unavailable for new encryption.", + }, + "decrypt": {"available": True, "reason": ""}, + } + assert "private backend detail" not in str(payload) + + def test_content_disposition_quotes_download_filename(): header = api_app._content_disposition("encrypted file.pqc") diff --git a/tests/test_crypto_core.py b/tests/test_crypto_core.py index 1d644f5..cd50f30 100644 --- a/tests/test_crypto_core.py +++ b/tests/test_crypto_core.py @@ -163,6 +163,17 @@ class FakeOQS: return FakeOQS +def test_available_decryption_kem_algorithms_preserves_exact_identities(monkeypatch): + """Archive availability lists exact current and legacy KEM identities.""" + monkeypatch.setattr( + core, + "_enabled_kem_mechanisms", + lambda: ("Kyber768", "ML-KEM-768", "Other"), + ) + + assert core.available_decryption_kem_algorithms() == ("ML-KEM-768", "Kyber768") + + def _legacy_v3_blob(plaintext: bytes, mlkem_public: bytes) -> bytes: """Build a valid legacy v3 container using the deterministic fake backend.""" ciphertext_kem = b"C" * 32 diff --git a/tests/test_startup.py b/tests/test_startup.py new file mode 100644 index 0000000..de68435 --- /dev/null +++ b/tests/test_startup.py @@ -0,0 +1,43 @@ +import os +from pathlib import Path +import subprocess + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] + + +def test_start_script_uses_api_app_when_legacy_flag_is_set(tmp_path): + argv_log = tmp_path / "interpreter-argv.txt" + interpreter = tmp_path / "python-stub" + startup_script = tmp_path / "start.sh" + static_app = tmp_path / "static" / "app" + static_app.mkdir(parents=True) + (static_app / "index.html").write_text("", encoding="utf-8") + startup_script.write_text((REPOSITORY_ROOT / "start.sh").read_text(encoding="utf-8"), encoding="utf-8") + startup_script.chmod(0o755) + interpreter.write_text( + "#!/usr/bin/env sh\n" "set -eu\n" 'printf "%s\\n" "$@" > "$STARTUP_ARGV_LOG"\n', + encoding="utf-8", + ) + interpreter.chmod(0o755) + + environment = os.environ.copy() + environment.update( + { + "PYTHON": str(interpreter), + "LEGACY_STREAMLIT": "1", + "SKIP_WEB_BUILD": "1", + "STARTUP_ARGV_LOG": str(argv_log), + } + ) + + completed = subprocess.run( + [str(startup_script)], + cwd=tmp_path, + env=environment, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stderr + assert argv_log.read_text(encoding="utf-8").splitlines() == ["-m", "api_app"] diff --git a/tsconfig.json b/tsconfig.json index 1a8a9f1..0dd3134 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,5 @@ "noEmit": true, "jsx": "react-jsx" }, - "include": ["web/src", "vite.config.ts"] + "include": ["web/src", "web/src/**/*.test.ts", "web/src/**/*.test.tsx", "vite.config.ts"] } diff --git a/ui_helpers.py b/ui_helpers.py index d78d6e9..56266ce 100644 --- a/ui_helpers.py +++ b/ui_helpers.py @@ -19,7 +19,7 @@ def guess_decrypted_filename(encrypted_filename: Path) -> str: def format_key_info_for_display(key_info: dict[str, object]) -> dict[str, str]: - """Format strict key-inspection metadata for Streamlit display.""" + """Format strict key-inspection metadata for local web display.""" display = { "Key Type": str(key_info["key_type"]).title(), "Algorithm": str(key_info["kem"]), diff --git a/vite.config.ts b/vite.config.ts index 52c0507..2778f58 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,3 +1,5 @@ +/// + import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; @@ -15,5 +17,11 @@ export default defineConfig({ build: { outDir: "../static/app", emptyOutDir: true + }, + test: { + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + clearMocks: true, + restoreMocks: true } }); diff --git a/web/src/App.tsx b/web/src/App.tsx deleted file mode 100644 index 923a8d3..0000000 --- a/web/src/App.tsx +++ /dev/null @@ -1,632 +0,0 @@ -import { useEffect, useMemo, useState } from "react"; -import { - ApiError, - decryptFile, - DownloadResult, - encryptFile, - fetchHealth, - generateKeys, - GeneratedKeys, - Health, - inspectKey, - KeyInspectResult -} from "./api"; - -type View = "encrypt" | "decrypt" | "generate" | "inspect"; -type NoticeKind = "info" | "success" | "warning" | "error"; -type Notice = { kind: NoticeKind; text: string }; - -const DEFAULT_HEALTH: Health = { - ok: true, - backendReady: false, - backendMessage: "Checking backend readiness.", - formatVersion: 4, - kem: "ML-KEM-768+X25519-v2", - kemComponent: "ML-KEM-768", - configuredKem: "ML-KEM-768", - dem: "AES-256-GCM", - maxFileBytes: 100 * 1024 * 1024, - maxEncryptedFileBytes: 101 * 1024 * 1024, - maxPemBytes: 128 * 1024, - apiToken: "", - passwordPolicy: { minChars: 16, minUniqueChars: 5 } -}; - -const views: Array<{ id: View; label: string; group: "File Workflows" | "Key Management" }> = [ - { id: "encrypt", label: "Encrypt File", group: "File Workflows" }, - { id: "decrypt", label: "Decrypt File", group: "File Workflows" }, - { id: "generate", label: "Generate Keys", group: "Key Management" }, - { id: "inspect", label: "Inspect Key", group: "Key Management" } -]; - -function formatBytes(bytes: number): string { - if (!Number.isFinite(bytes)) return "0 B"; - if (bytes < 1024) return `${bytes} B`; - const units = ["KiB", "MiB", "GiB"]; - let value = bytes / 1024; - let index = 0; - while (value >= 1024 && index < units.length - 1) { - value /= 1024; - index += 1; - } - return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[index]}`; -} - -function downloadBlob(result: DownloadResult): void { - const url = URL.createObjectURL(result.blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = result.filename; - document.body.append(anchor); - anchor.click(); - anchor.remove(); - window.setTimeout(() => URL.revokeObjectURL(url), 1000); -} - -function downloadText(filename: string, text: string): void { - downloadBlob({ filename, blob: new Blob([text], { type: "application/x-pem-file" }) }); -} - -function apiMessage(error: unknown): string { - if (error instanceof ApiError) return error.message; - if (error instanceof Error) return error.message; - return "Request failed."; -} - -function fileTooLarge(file: File | null, maxBytes: number): boolean { - return !!file && file.size > maxBytes; -} - -function sizeNotice(file: File | null, maxBytes: number, label: string): Notice | null { - if (!fileTooLarge(file, maxBytes)) return null; - return { - kind: "error", - text: `${label} is ${formatBytes(file?.size ?? 0)}. The maximum supported size is ${formatBytes(maxBytes)}.` - }; -} - -function suggestedEncryptedName(file: File | null): string { - if (!file) return "encrypted-file.pqc"; - const dot = file.name.lastIndexOf("."); - const stem = dot > 0 ? file.name.slice(0, dot) : file.name || "file"; - return `${stem}_encrypted.pqc`; -} - -function suggestedDecryptedName(file: File | null): string { - if (!file) return "decrypted.bin"; - const name = file.name; - if (name === ".pqc") return "decrypted.bin"; - if (name.endsWith("_encrypted.pqc")) return name.slice(0, -"_encrypted.pqc".length) || "decrypted.bin"; - if (name.endsWith(".pqc")) return `${name.slice(0, -".pqc".length)}_decrypted.bin`; - const dot = name.lastIndexOf("."); - if (dot > 0) return `${name.slice(0, dot)}_decrypted${name.slice(dot)}`; - return `${name}_decrypted.bin`; -} - -function useKeyInspection(file: File | null, maxPemBytes: number) { - const [result, setResult] = useState(null); - const [notice, setNotice] = useState(null); - const [loading, setLoading] = useState(false); - - useEffect(() => { - let cancelled = false; - setResult(null); - setNotice(null); - if (!file) return; - if (fileTooLarge(file, maxPemBytes)) { - setNotice(sizeNotice(file, maxPemBytes, "Key file")); - return; - } - setLoading(true); - inspectKey(file) - .then((payload) => { - if (!cancelled) setResult(payload); - }) - .catch((error) => { - if (!cancelled) setNotice({ kind: "error", text: apiMessage(error) }); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [file, maxPemBytes]); - - return { result, notice, loading }; -} - -function App() { - const [activeView, setActiveView] = useState("encrypt"); - const [health, setHealth] = useState(DEFAULT_HEALTH); - const [healthNotice, setHealthNotice] = useState(null); - - useEffect(() => { - let cancelled = false; - fetchHealth() - .then((payload) => { - if (!cancelled) setHealth(payload); - }) - .catch((error) => { - if (!cancelled) setHealthNotice({ kind: "error", text: apiMessage(error) }); - }); - return () => { - cancelled = true; - }; - }, []); - - const readinessNotice = healthNotice ?? { - kind: health.backendReady ? ("success" as const) : ("warning" as const), - text: health.backendMessage - }; - - return ( -

- -
- {activeView === "encrypt" && } - {activeView === "decrypt" && } - {activeView === "generate" && } - {activeView === "inspect" && } -
-
- ); -} - -function Sidebar({ activeView, health, onSelect }: { activeView: View; health: Health; onSelect: (view: View) => void }) { - return ( - - ); -} - -function MetaBadge({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} - -function NoticePanel({ notice }: { notice: Notice | null }) { - if (!notice) return null; - return ( -
- {notice.text} -
- ); -} - -function WorkflowHeader({ title, subtitle }: { title: string; subtitle: string }) { - return ( -
-
-

{title}

-

{subtitle}

-
-
- ); -} - -function Stepper({ steps, activeIndex }: { steps: string[]; activeIndex: number }) { - return ( -
    - {steps.map((step, index) => ( -
  1. - {index + 1} - {step} -
  2. - ))} -
- ); -} - -function FileDrop({ - id, - label, - hint, - accept, - file, - onFile -}: { - id: string; - label: string; - hint: string; - accept?: string; - file: File | null; - onFile: (file: File | null) => void; -}) { - return ( - - ); -} - -function FieldRow({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} - -function EncryptView({ health, readinessNotice }: { health: Health; readinessNotice: Notice }) { - const [file, setFile] = useState(null); - const [publicKey, setPublicKey] = useState(null); - const [outputName, setOutputName] = useState("encrypted-file.pqc"); - const [notice, setNotice] = useState(null); - const [working, setWorking] = useState(false); - const [downloadReady, setDownloadReady] = useState(false); - const keyInspection = useKeyInspection(publicKey, health.maxPemBytes); - - useEffect(() => { - setOutputName(suggestedEncryptedName(file)); - setDownloadReady(false); - }, [file]); - - const publicKeyReady = - keyInspection.result?.keyInfo.key_type === "public" && keyInspection.result.keyInfo.kem === health.kem; - const fileSizeNotice = sizeNotice(file, health.maxFileBytes, "Input file"); - const keySizeNotice = sizeNotice(publicKey, health.maxPemBytes, "Public key file"); - const activeIndex = downloadReady ? 4 : publicKeyReady && outputName ? 3 : file && publicKey ? 2 : file ? 1 : 0; - const canEncrypt = - health.backendReady && - !!file && - !fileSizeNotice && - publicKeyReady && - !keySizeNotice && - !!outputName && - !working; - - async function submit() { - if (!file || !publicKey || !canEncrypt) return; - setNotice(null); - setWorking(true); - try { - const result = await encryptFile(file, publicKey, outputName); - downloadBlob(result); - setDownloadReady(true); - setNotice({ kind: "success", text: `Encrypted file prepared as ${result.filename}.` }); - } catch (error) { - setNotice({ kind: "error", text: apiMessage(error) }); - } finally { - setWorking(false); - } - } - - return ( -
- - - -
-
-

Inputs

- - - - {keyInspection.loading && } - -
-
-

Review

- - - - - -
-
-
- ); -} - -function DecryptView({ health, readinessNotice }: { health: Health; readinessNotice: Notice }) { - const [file, setFile] = useState(null); - const [privateKey, setPrivateKey] = useState(null); - const [password, setPassword] = useState(""); - const [outputName, setOutputName] = useState("decrypted.bin"); - const [notice, setNotice] = useState(null); - const [working, setWorking] = useState(false); - const [downloadReady, setDownloadReady] = useState(false); - const keyInspection = useKeyInspection(privateKey, health.maxPemBytes); - - useEffect(() => { - setOutputName(suggestedDecryptedName(file)); - setDownloadReady(false); - }, [file]); - - const privateKeyReady = keyInspection.result?.keyInfo.key_type === "private"; - const fileSizeNotice = sizeNotice(file, health.maxEncryptedFileBytes, "Encrypted file"); - const keySizeNotice = sizeNotice(privateKey, health.maxPemBytes, "Private key file"); - const activeIndex = downloadReady ? 4 : privateKeyReady && password && outputName ? 3 : file && privateKey ? 2 : file ? 1 : 0; - const canDecrypt = - !!file && - !fileSizeNotice && - privateKeyReady && - !keySizeNotice && - !!password && - !!outputName && - !working; - - async function submit() { - if (!file || !privateKey || !canDecrypt) return; - setNotice(null); - setWorking(true); - try { - const result = await decryptFile(file, privateKey, password, outputName); - downloadBlob(result); - setDownloadReady(true); - setPassword(""); - setNotice({ kind: "success", text: `Decrypted file prepared as ${result.filename}.` }); - } catch (error) { - setNotice({ kind: "error", text: apiMessage(error) }); - } finally { - setWorking(false); - } - } - - return ( -
- - - -
-
-

Inputs

- - - - {keyInspection.loading && } - -
-
-

Review

- - - - - - -
-
-
- ); -} - -function GenerateKeysView({ health, readinessNotice }: { health: Health; readinessNotice: Notice }) { - const [password, setPassword] = useState(""); - const [confirm, setConfirm] = useState(""); - const [notice, setNotice] = useState(null); - const [working, setWorking] = useState(false); - const [keys, setKeys] = useState(null); - - const checks = useMemo( - () => [ - { label: `${health.passwordPolicy.minChars}+ characters`, ok: password.length >= health.passwordPolicy.minChars }, - { label: `${health.passwordPolicy.minUniqueChars}+ unique characters`, ok: new Set(password).size >= health.passwordPolicy.minUniqueChars }, - { label: "Confirmation matches", ok: password.length > 0 && password === confirm } - ], - [confirm, health.passwordPolicy.minChars, health.passwordPolicy.minUniqueChars, password] - ); - const canGenerate = health.backendReady && checks.every((check) => check.ok) && !working; - const activeIndex = keys ? 4 : canGenerate ? 2 : password || confirm ? 1 : 0; - - async function submit() { - if (!canGenerate) return; - setNotice(null); - setWorking(true); - setKeys(null); - try { - const result = await generateKeys(password); - setKeys(result); - setPassword(""); - setConfirm(""); - setNotice({ kind: "success", text: "Key pair generated. Download both PEM files now." }); - } catch (error) { - setNotice({ kind: "error", text: apiMessage(error) }); - } finally { - setWorking(false); - } - } - - return ( -
- - - -
-
-

Private key password

- - -
    - {checks.map((check) => ( -
  • -
  • - ))} -
- - -
-
-

Downloads

- {keys ? ( -
- - -
- ) : ( -

Generated PEM files will appear here for immediate download.

- )} -
-
-
- ); -} - -function InspectKeyView({ health }: { health: Health }) { - const [keyFile, setKeyFile] = useState(null); - const keyInspection = useKeyInspection(keyFile, health.maxPemBytes); - - return ( -
- - -
-
-

Key file

- - {keyInspection.loading && } - -
-
-

Metadata

- {keyInspection.result ? ( -
- {Object.entries(keyInspection.result.display).map(([label, value]) => ( - - ))} -
- ) : ( -

Supported key metadata will appear here.

- )} -
-
-
- ); -} - -export default App; diff --git a/web/src/api.ts b/web/src/api/client.ts similarity index 77% rename from web/src/api.ts rename to web/src/api/client.ts index ba32d30..9627710 100644 --- a/web/src/api.ts +++ b/web/src/api/client.ts @@ -1,47 +1,4 @@ -export type Health = { - ok: boolean; - backendReady: boolean; - backendMessage: string; - formatVersion: number; - kem: string; - kemComponent: string; - configuredKem: string; - dem: string; - maxFileBytes: number; - maxEncryptedFileBytes: number; - maxPemBytes: number; - apiToken: string; - passwordPolicy: { - minChars: number; - minUniqueChars: number; - }; -}; - -export type KeyInspectResult = { - ok: boolean; - keyInfo: { - kem: string; - key_type: "public" | "private"; - private_key_encrypted?: boolean; - private_key_format_version?: number; - private_key_kdf?: string; - }; - display: Record; -}; - -export type GeneratedKeys = { - ok: boolean; - kem: string; - publicPem: string; - privatePem: string; - publicFilename: string; - privateFilename: string; -}; - -export type DownloadResult = { - blob: Blob; - filename: string; -}; +import type { DownloadResult, GeneratedKeys, Health, KeyInspectResult } from "./contracts"; type ApiErrorPayload = { ok: false; @@ -138,28 +95,37 @@ export async function fetchHealth(): Promise { return healthRequest; } -export async function inspectKey(file: File): Promise { +export async function inspectKey(file: File, signal?: AbortSignal): Promise { const form = new FormData(); form.append("key", file); - const response = await fetchWithApiToken("/api/keys/inspect", { method: "POST", body: form }); + const response = await fetchWithApiToken("/api/keys/inspect", { + method: "POST", + body: form, + signal + }); if (!response.ok) await parseError(response); return (await response.json()) as KeyInspectResult; } -export async function generateKeys(password: string): Promise { +export async function generateKeys(password: string, signal?: AbortSignal): Promise { const form = new FormData(); form.append("password", password); - const response = await fetchWithApiToken("/api/keys/generate", { method: "POST", body: form }); + const response = await fetchWithApiToken("/api/keys/generate", { method: "POST", body: form, signal }); if (!response.ok) await parseError(response); return (await response.json()) as GeneratedKeys; } -export async function encryptFile(file: File, publicKey: File, outputFilename: string): Promise { +export async function encryptFile( + file: File, + publicKey: File, + outputFilename: string, + signal?: AbortSignal +): Promise { const form = new FormData(); form.append("file", file); form.append("public_key", publicKey); form.append("output_filename", outputFilename); - const response = await fetchWithApiToken("/api/files/encrypt", { method: "POST", body: form }); + const response = await fetchWithApiToken("/api/files/encrypt", { method: "POST", body: form, signal }); if (!response.ok) await parseError(response); return { blob: await response.blob(), @@ -171,14 +137,15 @@ export async function decryptFile( file: File, privateKey: File, password: string, - outputFilename: string + outputFilename: string, + signal?: AbortSignal ): Promise { const form = new FormData(); form.append("file", file); form.append("private_key", privateKey); form.append("password", password); form.append("output_filename", outputFilename); - const response = await fetchWithApiToken("/api/files/decrypt", { method: "POST", body: form }); + const response = await fetchWithApiToken("/api/files/decrypt", { method: "POST", body: form, signal }); if (!response.ok) await parseError(response); return { blob: await response.blob(), diff --git a/web/src/api/contracts.ts b/web/src/api/contracts.ts new file mode 100644 index 0000000..4aae2a1 --- /dev/null +++ b/web/src/api/contracts.ts @@ -0,0 +1,68 @@ +export type CapabilityName = "inspect" | "generate" | "encrypt" | "decrypt"; + +export type Capability = { + available: boolean; + reason: string; +}; + +export type Health = { + ok: boolean; + backendReady: boolean; + backendMessage: string; + capabilities: Record; + formatVersion: number; + kem: string; + kemComponent: string; + configuredKem: string; + dem: string; + maxFileBytes: number; + maxEncryptedFileBytes: number; + maxPemBytes: number; + apiToken: string; + passwordPolicy: { + minChars: number; + minUniqueChars: number; + }; +}; + +export type KeyInspectResult = { + ok: boolean; + keyInfo: { + kem: string; + key_type: "public" | "private"; + private_key_encrypted?: boolean; + private_key_format_version?: number; + private_key_kdf?: string; + }; + display: Record; +}; + +export type GeneratedKeys = { + ok: boolean; + kem: string; + publicPem: string; + privatePem: string; + publicFilename: string; + privateFilename: string; +}; + +export type DownloadResult = { + blob: Blob; + filename: string; +}; + +export type InspectKeyOperation = (file: File, signal?: AbortSignal) => Promise; +export type GenerateKeysOperation = (password: string, signal?: AbortSignal) => Promise; +export type EncryptFileOperation = ( + file: File, + publicKey: File, + outputFilename: string, + signal?: AbortSignal +) => Promise; +export type DecryptFileOperation = ( + file: File, + privateKey: File, + password: string, + outputFilename: string, + signal?: AbortSignal +) => Promise; diff --git a/web/src/api/errors.ts b/web/src/api/errors.ts new file mode 100644 index 0000000..68c278a --- /dev/null +++ b/web/src/api/errors.ts @@ -0,0 +1,18 @@ +import { ApiError } from "./client"; + +const LOCAL_SERVICE_UNAVAILABLE = "Could not reach the local service. Check that it is running and try again."; + +export function isAbortError(error: unknown): boolean { + return error instanceof DOMException + ? error.name === "AbortError" + : error instanceof Error && error.name === "AbortError"; +} + +export function safeOperationError(error: unknown, fallback: string): string { + if (error instanceof ApiError) { + if (error.status < 500 || error.code === "backend_unavailable") return error.message; + return fallback; + } + if (error instanceof TypeError) return LOCAL_SERVICE_UNAVAILABLE; + return fallback; +} diff --git a/web/src/api/index.ts b/web/src/api/index.ts new file mode 100644 index 0000000..589823a --- /dev/null +++ b/web/src/api/index.ts @@ -0,0 +1,13 @@ +export { ApiError, decryptFile, encryptFile, fetchHealth, generateKeys, inspectKey } from "./client"; +export type { + Capability, + CapabilityName, + DecryptFileOperation, + DownloadResult, + EncryptFileOperation, + GeneratedKeys, + GenerateKeysOperation, + Health, + InspectKeyOperation, + KeyInspectResult +} from "./contracts"; diff --git a/web/src/app/App.test.tsx b/web/src/app/App.test.tsx new file mode 100644 index 0000000..9c4c4c1 --- /dev/null +++ b/web/src/app/App.test.tsx @@ -0,0 +1,130 @@ +import { StrictMode } from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { READY_HEALTH } from "../test/fixtures"; +import App from "./App"; + +const client = vi.hoisted(() => ({ + fetchHealth: vi.fn(), + generateKeys: vi.fn() +})); + +vi.mock("../api/client", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchHealth: client.fetchHealth, + generateKeys: client.generateKeys + }; +}); + +async function openGeneratedKeys(user: ReturnType) { + await screen.findByRole("heading", { name: "Encrypt a file" }); + await user.click(screen.getByRole("button", { name: "Generate keys" })); + await screen.findByRole("heading", { name: "Generate keys" }); + await user.type(screen.getByLabelText("Private key password"), "correct horse battery staple"); + await user.type(screen.getByLabelText("Confirm private key password"), "correct horse battery staple"); + await user.click(screen.getByRole("button", { name: "Generate key pair" })); + await screen.findByRole("button", { name: "Download public key" }); +} + +beforeEach(() => { + client.fetchHealth.mockReset(); + client.fetchHealth.mockResolvedValue(READY_HEALTH); + client.generateKeys.mockReset(); + client.generateKeys.mockResolvedValue({ + ok: true, + kem: READY_HEALTH.kem, + publicPem: "PUBLIC-PEM", + privatePem: "ENCRYPTED-PRIVATE-PEM", + publicFilename: "recipient-public.pem", + privateFilename: "recipient-private.pem" + }); +}); + +describe("App", () => { + it("starts on file encryption and switches to key inspection", async () => { + const user = userEvent.setup(); + + render(); + + expect(await screen.findByRole("heading", { name: "Encrypt a file" })).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Inspect key" })); + + expect(await screen.findByRole("heading", { name: "Inspect a key" })).toBeVisible(); + }); + + it("coalesces the initial health request in StrictMode while workflows stay unmounted", async () => { + let resolveHealth: (health: typeof READY_HEALTH) => void = () => undefined; + const pendingHealth = new Promise((resolve) => { + resolveHealth = resolve; + }); + client.fetchHealth.mockReturnValue(pendingHealth); + + render( + + + + ); + + expect(client.fetchHealth).toHaveBeenCalledTimes(1); + expect(screen.getByRole("status")).toHaveTextContent("Loading local engine status."); + expect(screen.queryByRole("heading", { name: "Encrypt a file" })).not.toBeInTheDocument(); + + resolveHealth(READY_HEALTH); + + expect(await screen.findByRole("heading", { name: "Encrypt a file" })).toBeVisible(); + }); + + it("retries a failed health request with a fresh request and safe recovery", async () => { + client.fetchHealth.mockRejectedValueOnce(new Error("raw backend connection details")); + client.fetchHealth.mockResolvedValueOnce(READY_HEALTH); + const user = userEvent.setup(); + + render(); + + expect( + await screen.findByText("The local engine status could not be loaded. Restart the app and try again.") + ).toBeVisible(); + expect(screen.getByRole("button", { name: "Retry" })).toBeEnabled(); + expect(screen.queryByText("raw backend connection details")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Retry" })); + + expect(client.fetchHealth).toHaveBeenCalledTimes(2); + expect(await screen.findByRole("heading", { name: "Encrypt a file" })).toBeVisible(); + }); + + it("keeps generated keys active when leaving is cancelled", async () => { + const user = userEvent.setup(); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + + render(); + await openGeneratedKeys(user); + await user.click(screen.getByRole("button", { name: "Inspect key" })); + + expect(confirm).toHaveBeenCalledWith("Generated keys are still available. Leave this workflow and clear them?"); + expect(screen.getByRole("heading", { name: "Generate keys" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Download public key" })).toBeVisible(); + }); + + it("clears generated-key state before leaving after confirmation", async () => { + const user = userEvent.setup(); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(true); + + render(); + await openGeneratedKeys(user); + await user.click(screen.getByRole("button", { name: "Inspect key" })); + + expect(confirm).toHaveBeenCalledWith("Generated keys are still available. Leave this workflow and clear them?"); + expect(await screen.findByRole("heading", { name: "Inspect a key" })).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Generate keys" })); + await screen.findByRole("heading", { name: "Generate keys" }); + await user.click(screen.getByRole("button", { name: "Encrypt" })); + + await waitFor(() => expect(screen.getByRole("heading", { name: "Encrypt a file" })).toBeVisible()); + expect(confirm).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx new file mode 100644 index 0000000..1cb7e03 --- /dev/null +++ b/web/src/app/App.tsx @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchHealth } from "../api/client"; +import type { Health } from "../api/contracts"; +import { DecryptWorkflow } from "../features/decrypt/DecryptWorkflow"; +import { EncryptWorkflow } from "../features/encrypt/EncryptWorkflow"; +import { GenerateKeysWorkflow } from "../features/generate/GenerateKeysWorkflow"; +import { InspectKeyWorkflow } from "../features/inspect/InspectKeyWorkflow"; +import { AppShell } from "./AppShell"; +import type { View } from "./navigation"; + +const SENSITIVE_RESULT_CONFIRMATION = "Generated keys are still available. Leave this workflow and clear them?"; + +export default function App() { + const [activeView, setActiveView] = useState("encrypt"); + const [health, setHealth] = useState(null); + const [healthError, setHealthError] = useState(null); + const [hasGeneratedKeys, setHasGeneratedKeys] = useState(false); + const healthRequestId = useRef(0); + const initialHealthRequest = useRef | null>(null); + + const loadHealth = useCallback(async (retry = false) => { + const requestId = healthRequestId.current + 1; + healthRequestId.current = requestId; + setHealth(null); + setHealthError(null); + const request = retry + ? fetchHealth() + : (initialHealthRequest.current ?? (initialHealthRequest.current = fetchHealth())); + + try { + const nextHealth = await request; + if (requestId === healthRequestId.current) setHealth(nextHealth); + } catch { + if (requestId === healthRequestId.current) { + setHealthError("The local engine status could not be loaded. Restart the app and try again."); + } + } + }, []); + + useEffect(() => { + void loadHealth(); + return () => { + healthRequestId.current += 1; + }; + }, [loadHealth]); + + function navigate(nextView: View) { + if (nextView === activeView) return; + if (activeView === "generate" && hasGeneratedKeys) { + if (!window.confirm(SENSITIVE_RESULT_CONFIRMATION)) return; + setHasGeneratedKeys(false); + } + setActiveView(nextView); + } + + if (!health) { + return ( +
+ {healthError ? ( + <> +

Local engine unavailable

+

{healthError}

+ + + ) : ( +

Loading local engine status.

+ )} +
+ ); + } + + return ( + + {activeView === "encrypt" && } + {activeView === "decrypt" && } + {activeView === "generate" && ( + + )} + {activeView === "inspect" && } + + ); +} diff --git a/web/src/app/AppShell.test.tsx b/web/src/app/AppShell.test.tsx new file mode 100644 index 0000000..17ee297 --- /dev/null +++ b/web/src/app/AppShell.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { expect, it, vi } from "vitest"; +import { READY_HEALTH } from "../test/fixtures"; +import { AppShell } from "./AppShell"; + +it("exposes semantic navigation and changes workflows", async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn(); + + render( + +

Encrypt a file

+
+ ); + + expect(screen.getByRole("navigation", { name: "Workflows" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Encrypt" })).toHaveAttribute("aria-current", "page"); + + await user.click(screen.getByRole("button", { name: "Decrypt" })); + + expect(onNavigate).toHaveBeenCalledWith("decrypt"); + expect(screen.getByText("Local engine")).toBeVisible(); +}); + +it("keeps a supplied availability reason in capability details", () => { + const health = { + ...READY_HEALTH, + capabilities: { + ...READY_HEALTH.capabilities, + encrypt: { + available: true, + reason: "Uses the device-bound crypto provider." + } + } + }; + + render( + undefined}> +

Encrypt a file

+
+ ); + + expect(screen.getAllByText("Uses the device-bound crypto provider.")).toHaveLength(2); +}); + +it("places narrow-layout workflow navigation before the main content in keyboard order", () => { + render( + undefined}> +

Encrypt a file

+
+ ); + + const mobileNavigation = screen.getByRole("navigation", { name: "Mobile workflows" }); + const main = screen.getByRole("main"); + + expect(mobileNavigation.compareDocumentPosition(main) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(mobileNavigation).toContainElement(screen.getByRole("link", { name: "Encrypt" })); +}); diff --git a/web/src/app/AppShell.tsx b/web/src/app/AppShell.tsx new file mode 100644 index 0000000..68c5567 --- /dev/null +++ b/web/src/app/AppShell.tsx @@ -0,0 +1,99 @@ +import type { ReactNode } from "react"; +import type { Health } from "../api"; +import { CapabilityStatus } from "../components/CapabilityStatus"; +import "../styles/components.css"; +import { NAV_ITEMS, type View } from "./navigation"; + +export type AppShellProps = { + activeView: View; + health: Health; + onNavigate: (view: View) => void; + children: ReactNode; +}; + +function Brand() { + return ( +
+ + + Quantum Encryptor + Local file security + +
+ ); +} + +function RailNavigation({ activeView, onNavigate }: Pick) { + return ( + + ); +} + +function HeaderNavigation({ activeView, health, onNavigate }: Omit) { + return ( +
+ + + +
+ ); +} + +function MobileNavigation({ activeView, onNavigate }: Pick) { + return ( +
+ ); +} + +export function AppShell({ activeView, health, onNavigate, children }: AppShellProps) { + return ( +
+ + + +
{children}
+
+ ); +} diff --git a/web/src/app/navigation.ts b/web/src/app/navigation.ts new file mode 100644 index 0000000..d1b922d --- /dev/null +++ b/web/src/app/navigation.ts @@ -0,0 +1,8 @@ +export type View = "encrypt" | "decrypt" | "generate" | "inspect"; + +export const NAV_ITEMS: ReadonlyArray<{ id: View; label: string }> = [ + { id: "encrypt", label: "Encrypt" }, + { id: "decrypt", label: "Decrypt" }, + { id: "generate", label: "Generate keys" }, + { id: "inspect", label: "Inspect key" } +]; diff --git a/web/src/components/ActionButton.tsx b/web/src/components/ActionButton.tsx new file mode 100644 index 0000000..1649449 --- /dev/null +++ b/web/src/components/ActionButton.tsx @@ -0,0 +1,14 @@ +import type { ButtonHTMLAttributes } from "react"; + +export type ActionButtonProps = ButtonHTMLAttributes & { + busy?: boolean; + busyLabel: string; +}; + +export function ActionButton({ busy = false, busyLabel, children, disabled, ...props }: ActionButtonProps) { + return ( + + ); +} diff --git a/web/src/components/CapabilityStatus.tsx b/web/src/components/CapabilityStatus.tsx new file mode 100644 index 0000000..1225722 --- /dev/null +++ b/web/src/components/CapabilityStatus.tsx @@ -0,0 +1,60 @@ +import type { Health } from "../api"; + +type CapabilityStatusProps = { + health: Health; + compact?: boolean; +}; + +const capabilityLabels = { + encrypt: "Encrypt", + decrypt: "Decrypt", + generate: "Generate keys", + inspect: "Inspect key" +} as const; + +function availabilityLabel(available: number): "Ready" | "Limited" | "Unavailable" { + if (available === 4) return "Ready"; + if (available === 0) return "Unavailable"; + return "Limited"; +} + +export function CapabilityStatus({ health, compact = false }: CapabilityStatusProps) { + const available = Object.values(health.capabilities).filter((capability) => capability.available).length; + const status = availabilityLabel(available); + const statusClass = status.toLowerCase(); + + return ( +
+

+

+
+ Capability details +
    + {(Object.keys(capabilityLabels) as Array).map((operation) => { + const capability = health.capabilities[operation]; + const reason = capability.reason || (capability.available ? "Available" : "Unavailable"); + return ( +
  • + {capabilityLabels[operation]} + {reason} +
  • + ); + })} +
+
+
+ ); +} diff --git a/web/src/components/FilePicker.tsx b/web/src/components/FilePicker.tsx new file mode 100644 index 0000000..ef1e8a6 --- /dev/null +++ b/web/src/components/FilePicker.tsx @@ -0,0 +1,69 @@ +import type { ChangeEvent, DragEvent } from "react"; +import { formatBytes } from "../lib/format"; + +export type FilePickerProps = { + id: string; + label: string; + hint: string; + accept?: string; + file: File | null; + error?: string; + disabled?: boolean; + onFile: (file: File | null) => void; +}; + +export function FilePicker({ id, label, hint, accept, file, error, disabled = false, onFile }: FilePickerProps) { + const descriptionId = `${id}-description`; + const errorId = `${id}-error`; + const describedBy = error ? `${descriptionId} ${errorId}` : descriptionId; + + function selectFile(event: ChangeEvent) { + if (disabled) return; + onFile(event.target.files?.item(0) ?? null); + } + + function allowDrop(event: DragEvent) { + event.preventDefault(); + } + + function dropFile(event: DragEvent) { + event.preventDefault(); + if (disabled) return; + onFile(event.dataTransfer.files.item(0) ?? null); + } + + return ( +
+ +

+ {hint} +

+ {error && ( + + )} +
+ ); +} diff --git a/web/src/components/Notice.tsx b/web/src/components/Notice.tsx new file mode 100644 index 0000000..61fa12a --- /dev/null +++ b/web/src/components/Notice.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; + +export type NoticeKind = "info" | "success" | "warning" | "error"; + +export type NoticeProps = { + kind: NoticeKind; + title?: string; + children: ReactNode; +}; + +export function Notice({ kind, title, children }: NoticeProps) { + return ( +
+ {title && {title}} +
{children}
+
+ ); +} diff --git a/web/src/components/PasswordField.tsx b/web/src/components/PasswordField.tsx new file mode 100644 index 0000000..f388fee --- /dev/null +++ b/web/src/components/PasswordField.tsx @@ -0,0 +1,35 @@ +import { useState } from "react"; + +export type PasswordFieldProps = { + id: string; + label: string; + value: string; + autoComplete: "new-password" | "current-password"; + describedBy?: string; + disabled?: boolean; + onChange: (value: string) => void; +}; + +export function PasswordField({ id, label, value, autoComplete, describedBy, disabled = false, onChange }: PasswordFieldProps) { + const [visible, setVisible] = useState(false); + + return ( +
+ +
+ onChange(event.target.value)} + type={visible ? "text" : "password"} + value={value} + /> + +
+
+ ); +} diff --git a/web/src/components/TechnicalDetails.tsx b/web/src/components/TechnicalDetails.tsx new file mode 100644 index 0000000..d7dff16 --- /dev/null +++ b/web/src/components/TechnicalDetails.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from "react"; + +export type TechnicalDetailsProps = { + children: ReactNode; +}; + +export function TechnicalDetails({ children }: TechnicalDetailsProps) { + return ( +
+ Technical details +
{children}
+
+ ); +} diff --git a/web/src/components/WorkflowLayout.tsx b/web/src/components/WorkflowLayout.tsx new file mode 100644 index 0000000..d1a5817 --- /dev/null +++ b/web/src/components/WorkflowLayout.tsx @@ -0,0 +1,39 @@ +import type { ReactNode } from "react"; +import type { Capability } from "../api"; +import type { WorkflowPhase } from "../lib/workflow"; +import { Notice } from "./Notice"; + +export type WorkflowLayoutProps = { + title: string; + description: string; + phase: WorkflowPhase; + capability: Capability; + busy?: boolean; + children: ReactNode; +}; + +const phaseLabels: Record = { + select: "Select", + review: "Review", + complete: "Complete" +}; + +export function WorkflowLayout({ title, description, phase, capability, busy = false, children }: WorkflowLayoutProps) { + return ( +
+
+

{title}

+

{description}

+
+
    + {(Object.keys(phaseLabels) as WorkflowPhase[]).map((value) => ( +
  1. + {phaseLabels[value]} +
  2. + ))} +
+ {!capability.available && {capability.reason}} +
{children}
+
+ ); +} diff --git a/web/src/components/components.test.tsx b/web/src/components/components.test.tsx new file mode 100644 index 0000000..0364ce9 --- /dev/null +++ b/web/src/components/components.test.tsx @@ -0,0 +1,83 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { FilePicker } from "./FilePicker"; +import { PasswordField } from "./PasswordField"; +import { TechnicalDetails } from "./TechnicalDetails"; + +describe("shared controls", () => { + it("selects a file through the labeled native input", async () => { + const user = userEvent.setup(); + const onFile = vi.fn(); + render( + + ); + const file = new File(["hello"], "hello.txt", { type: "text/plain" }); + await user.upload(screen.getByLabelText("File to protect"), file); + expect(onFile).toHaveBeenCalledWith(file); + }); + + it("displays selected file sizes with the shared binary precision", () => { + const file = new File([new Uint8Array(1024)], "key.pem", { type: "application/x-pem-file" }); + render( + undefined} + /> + ); + expect(screen.getByText("key.pem · 1.00 KiB")).toBeVisible(); + }); + + it("keeps the visually hidden native file input in keyboard focus order", async () => { + const user = userEvent.setup(); + render( + undefined} + /> + ); + + await user.tab(); + + expect(screen.getByLabelText("Keyboard file")).toHaveFocus(); + }); + + it("announces password visibility through the button label", async () => { + const user = userEvent.setup(); + render( + undefined} + autoComplete="current-password" + /> + ); + await user.click(screen.getByRole("button", { name: "Show password" })); + expect(screen.getByLabelText("Private key password")).toHaveAttribute("type", "text"); + expect(screen.getByRole("button", { name: "Hide password" })).toBeVisible(); + }); + + it("uses a native disclosure with accurate expanded state", async () => { + const user = userEvent.setup(); + render( + + ML-KEM-768 + X25519 + + ); + await user.click(screen.getByText("Technical details")); + expect(screen.getByText("ML-KEM-768 + X25519")).toBeVisible(); + }); +}); diff --git a/web/src/features/decrypt/DecryptWorkflow.test.tsx b/web/src/features/decrypt/DecryptWorkflow.test.tsx new file mode 100644 index 0000000..a62bf6e --- /dev/null +++ b/web/src/features/decrypt/DecryptWorkflow.test.tsx @@ -0,0 +1,306 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ApiError } from "../../api"; +import { READY_HEALTH } from "../../test/fixtures"; +import { DecryptWorkflow } from "./DecryptWorkflow"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +function privateKeyInspection() { + return { + ok: true, + keyInfo: { + kem: READY_HEALTH.kem, + key_type: "private" as const, + private_key_encrypted: true, + private_key_format_version: 3, + private_key_kdf: "scrypt" + }, + display: { + "Key Type": "Encrypted private key", + "Key Format": "3", + "Key KDF": "scrypt", + Algorithm: READY_HEALTH.kem + } + }; +} + +describe("DecryptWorkflow", () => { + it("decrypts with an inspected private key, saves the suggested filename, and clears the password", async () => { + const inspect = vi.fn().mockResolvedValue(privateKeyInspection()); + const decrypt = vi.fn().mockResolvedValue({ + filename: "report", + blob: new Blob(["plaintext"]) + }); + const save = vi.fn(); + const user = userEvent.setup(); + + render(); + + await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc")); + await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem")); + expect(await screen.findByText("Supported encrypted private key; match not yet verified")).toBeVisible(); + + const password = screen.getByLabelText("Private key password"); + await user.type(password, "correct horse battery staple"); + expect(screen.getByLabelText("Output filename")).toHaveValue("report"); + + await user.click(screen.getByRole("button", { name: "Decrypt file" })); + + await waitFor(() => + expect(decrypt).toHaveBeenCalledWith( + expect.objectContaining({ name: "report_encrypted.pqc" }), + expect.objectContaining({ name: "recipient_private.pem" }), + "correct horse battery staple", + "report", + expect.any(AbortSignal) + ) + ); + expect(save).toHaveBeenCalledWith(expect.objectContaining({ filename: "report" })); + expect(password).toHaveValue(""); + }); + + it("reports a browser download failure separately and still clears the password", async () => { + const inspect = vi.fn().mockResolvedValue(privateKeyInspection()); + const decrypt = vi.fn().mockResolvedValue({ filename: "report", blob: new Blob(["plaintext"]) }); + const save = vi.fn(() => { + throw new TypeError("browser download failed"); + }); + const user = userEvent.setup(); + + render(); + await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc")); + await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem")); + await screen.findByText("Supported encrypted private key; match not yet verified"); + const password = screen.getByLabelText("Private key password"); + await user.type(password, "correct horse battery staple"); + await user.click(screen.getByRole("button", { name: "Decrypt file" })); + + expect( + await screen.findByText("The file was decrypted, but the download could not start. Try decrypting it again.") + ).toBeVisible(); + expect(screen.queryByText(/could not reach the local service/i)).not.toBeInTheDocument(); + expect(password).toHaveValue(""); + }); + + it("uses a safe authentication failure message without singling out a secret", async () => { + const inspect = vi.fn().mockResolvedValue(privateKeyInspection()); + const decrypt = vi.fn().mockRejectedValue( + new ApiError( + 400, + "decryption_failed", + "Decryption failed. Check the private key, password, and encrypted file integrity." + ) + ); + const user = userEvent.setup(); + + render(); + + await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc")); + await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem")); + await screen.findByText("Supported encrypted private key; match not yet verified"); + const password = screen.getByLabelText("Private key password"); + await user.type(password, "correct horse battery staple"); + await user.click(screen.getByRole("button", { name: "Decrypt file" })); + + expect( + await screen.findByText("The file could not be authenticated. Check the encrypted file, private key, and password.") + ).toBeVisible(); + expect(screen.queryByText(/decryption failed\. check/i)).not.toBeInTheDocument(); + expect(password).toHaveValue("correct horse battery staple"); + }); + + it("locks every causal input while a decryption request is in flight", async () => { + const inspection = vi.fn().mockResolvedValue(privateKeyInspection()); + const request = deferred<{ filename: string; blob: Blob }>(); + const decrypt = vi.fn().mockReturnValue(request.promise); + const user = userEvent.setup(); + + render(); + + await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc")); + await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem")); + await screen.findByText("Supported encrypted private key; match not yet verified"); + const password = screen.getByLabelText("Private key password"); + await user.type(password, "correct horse battery staple"); + await user.click(screen.getByRole("button", { name: "Decrypt file" })); + await waitFor(() => expect(decrypt).toHaveBeenCalledTimes(1)); + + expect(screen.getByLabelText("Encrypted file")).toBeDisabled(); + expect(screen.getByLabelText("Private key")).toBeDisabled(); + expect(password).toBeDisabled(); + expect(screen.getByLabelText("Output filename")).toBeDisabled(); + await user.type(password, "x"); + expect(password).toHaveValue("correct horse battery staple"); + + request.resolve({ filename: "report", blob: new Blob(["plaintext"]) }); + }); + + it("clears a completed decryption outcome when the password changes while idle", async () => { + const inspect = vi.fn().mockResolvedValue(privateKeyInspection()); + const decrypt = vi.fn().mockResolvedValue({ filename: "report", blob: new Blob(["plaintext"]) }); + const user = userEvent.setup(); + + render(); + + await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc")); + await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem")); + await screen.findByText("Supported encrypted private key; match not yet verified"); + const password = screen.getByLabelText("Private key password"); + await user.type(password, "correct horse battery staple"); + await user.click(screen.getByRole("button", { name: "Decrypt file" })); + expect(await screen.findByText("Saved report.")).toBeVisible(); + + await user.type(password, "new"); + + expect(screen.queryByText("Saved report.")).not.toBeInTheDocument(); + expect(screen.queryByText("File decrypted")).not.toBeInTheDocument(); + }); + + it("hides inspection failure details while keeping decryption unavailable", async () => { + const inspect = vi.fn().mockRejectedValue(new Error("private parser detail: /secret/path/key.pem")); + const user = userEvent.setup(); + + render(); + + await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem")); + + expect(await screen.findByText("The private key could not be inspected.")).toBeVisible(); + expect(screen.queryByText(/private parser detail/i)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Decrypt file" })).toBeDisabled(); + }); + + it("rejects oversized encrypted files locally before decryption", async () => { + const health = { ...READY_HEALTH, maxEncryptedFileBytes: 3 }; + const decrypt = vi.fn(); + const user = userEvent.setup(); + + render(); + + await user.upload(screen.getByLabelText("Encrypted file"), new File(["four"], "report_encrypted.pqc")); + + expect(screen.getByText("This encrypted file exceeds the 3 byte limit.")).toBeVisible(); + expect(screen.getByRole("button", { name: "Decrypt file" })).toBeDisabled(); + expect(decrypt).not.toHaveBeenCalled(); + }); + + it("accepts a legacy private key classification without rejecting its suite before authentication", async () => { + const inspect = vi.fn().mockResolvedValue({ + ...privateKeyInspection(), + keyInfo: { + ...privateKeyInspection().keyInfo, + kem: "ML-KEM-768+X25519" + } + }); + const decrypt = vi.fn().mockResolvedValue({ filename: "report", blob: new Blob(["plaintext"]) }); + const user = userEvent.setup(); + + render(); + + await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc")); + await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem")); + await screen.findByText("Supported encrypted private key; match not yet verified"); + await user.type(screen.getByLabelText("Private key password"), "correct horse battery staple"); + await user.click(screen.getByRole("button", { name: "Decrypt file" })); + + await waitFor(() => expect(decrypt).toHaveBeenCalledTimes(1)); + }); + + it("does not claim a selected container version or pre-authentication key match", async () => { + const inspect = vi.fn().mockResolvedValue(privateKeyInspection()); + const user = userEvent.setup(); + + render(); + await user.upload(screen.getByLabelText("Encrypted file"), new File(["legacy"], "archive.pqc")); + await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "archive-private.pem")); + + expect(await screen.findByText("Supported encrypted private key; match not yet verified")).toBeVisible(); + expect(screen.queryByText("Compatible private key")).not.toBeInTheDocument(); + expect(screen.queryByText("File format version")).not.toBeInTheDocument(); + }); + + it("aborts an in-flight decryption request on unmount", async () => { + const inspect = vi.fn().mockResolvedValue(privateKeyInspection()); + const decrypt = vi.fn( + (_file: File, _privateKey: File, _password: string, _outputFilename: string, _signal?: AbortSignal) => + new Promise<{ filename: string; blob: Blob }>(() => undefined) + ); + const user = userEvent.setup(); + const { unmount } = render( + + ); + + await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc")); + await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem")); + await screen.findByText("Supported encrypted private key; match not yet verified"); + await user.type(screen.getByLabelText("Private key password"), "correct horse battery staple"); + await user.click(screen.getByRole("button", { name: "Decrypt file" })); + await waitFor(() => expect(decrypt).toHaveBeenCalledTimes(1)); + const signal = decrypt.mock.calls[0]?.[4] as AbortSignal; + + unmount(); + + expect(signal.aborted).toBe(true); + }); + + it("preserves backend and invalid-key guidance while distinguishing network failure", async () => { + const inspect = vi.fn().mockResolvedValue(privateKeyInspection()); + const user = userEvent.setup(); + const renderFailure = async (failure: unknown) => { + const decrypt = vi.fn().mockRejectedValue(failure); + const rendered = render( + + ); + await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc")); + await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem")); + await screen.findByText("Supported encrypted private key; match not yet verified"); + await user.type(screen.getByLabelText("Private key password"), "correct horse battery staple"); + await user.click(screen.getByRole("button", { name: "Decrypt file" })); + return rendered; + }; + + let rendered = await renderFailure(new ApiError(503, "backend_unavailable", "Post-quantum backend is not ready.")); + expect(await screen.findByText("Post-quantum backend is not ready.")).toBeVisible(); + rendered.unmount(); + + rendered = await renderFailure( + new ApiError(400, "invalid_private_key", "Upload a supported encrypted PQC private key PEM file.") + ); + expect(await screen.findByText("Upload a supported encrypted PQC private key PEM file.")).toBeVisible(); + rendered.unmount(); + + await renderFailure(new TypeError("fetch failed: private socket detail")); + expect(await screen.findByText("Could not reach the local service. Check that it is running and try again.")).toBeVisible(); + expect(screen.queryByText(/private socket detail/i)).not.toBeInTheDocument(); + }); + + it("does not render an AbortError as a decryption failure", async () => { + const inspect = vi.fn().mockResolvedValue(privateKeyInspection()); + const aborted = new Error("request aborted"); + aborted.name = "AbortError"; + const decrypt = vi.fn().mockRejectedValue(aborted); + const user = userEvent.setup(); + + render(); + await user.upload(screen.getByLabelText("Encrypted file"), new File(["ciphertext"], "report_encrypted.pqc")); + await user.upload(screen.getByLabelText("Private key"), new File(["PEM"], "recipient_private.pem")); + await screen.findByText("Supported encrypted private key; match not yet verified"); + await user.type(screen.getByLabelText("Private key password"), "correct horse battery staple"); + await user.click(screen.getByRole("button", { name: "Decrypt file" })); + + await waitFor(() => expect(screen.getByRole("button", { name: "Decrypt file" })).toBeEnabled()); + expect(screen.queryByText("Decryption failed")).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/features/decrypt/DecryptWorkflow.tsx b/web/src/features/decrypt/DecryptWorkflow.tsx new file mode 100644 index 0000000..49a4316 --- /dev/null +++ b/web/src/features/decrypt/DecryptWorkflow.tsx @@ -0,0 +1,298 @@ +import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; +import { + ApiError, + decryptFile, + type DecryptFileOperation, + type DownloadResult, + type Health, + type InspectKeyOperation +} from "../../api"; +import { isAbortError, safeOperationError } from "../../api/errors"; +import { ActionButton } from "../../components/ActionButton"; +import { FilePicker } from "../../components/FilePicker"; +import { Notice } from "../../components/Notice"; +import { PasswordField } from "../../components/PasswordField"; +import { TechnicalDetails } from "../../components/TechnicalDetails"; +import { WorkflowLayout } from "../../components/WorkflowLayout"; +import { useKeyInspection } from "../../hooks/useKeyInspection"; +import { downloadBlob } from "../../lib/download"; +import { suggestedDecryptedName } from "../../lib/filenames"; +import { formatBytes } from "../../lib/format"; +import { deriveWorkflowPhase } from "../../lib/workflow"; + +export type DecryptWorkflowProps = { + health: Health; + inspect?: InspectKeyOperation; + decrypt?: DecryptFileOperation; + save?: (result: DownloadResult) => void; +}; + +function limitMessage(label: string, maxBytes: number): string { + return `${label} exceeds the ${maxBytes.toLocaleString()} byte limit.`; +} + +function keyFormat(inspection: ReturnType["result"]): string { + const version = inspection?.keyInfo.private_key_format_version; + return version === undefined ? "Not available" : String(version); +} + +const AUTHENTICATION_ERROR_CODES = new Set(["decryption_failed", "private_key_failed"]); +const AUTHENTICATION_FAILURE = + "The file could not be authenticated. Check the encrypted file, private key, and password."; + +function decryptionError(caught: unknown): string { + if (caught instanceof ApiError && AUTHENTICATION_ERROR_CODES.has(caught.code)) return AUTHENTICATION_FAILURE; + return safeOperationError(caught, "The local service could not complete decryption. Try again."); +} + +export function DecryptWorkflow({ + health, + inspect, + decrypt = decryptFile, + save = downloadBlob +}: DecryptWorkflowProps) { + const [file, setFile] = useState(null); + const [privateKey, setPrivateKey] = useState(null); + const [password, setPassword] = useState(""); + const [outputFilename, setOutputFilename] = useState(""); + const [error, setError] = useState(null); + const [completedFilename, setCompletedFilename] = useState(null); + const [busy, setBusy] = useState(false); + const mountedRef = useRef(true); + const requestIdRef = useRef(0); + const inFlightRef = useRef(false); + const abortControllerRef = useRef(null); + const capability = health.capabilities.decrypt; + const { result: inspection, error: inspectionError, loading: inspecting } = useKeyInspection( + capability.available ? privateKey : null, + health.maxPemBytes, + inspect + ); + const fileError = file && file.size > health.maxEncryptedFileBytes + ? limitMessage("This encrypted file", health.maxEncryptedFileBytes) + : null; + const keyError = privateKey && privateKey.size > health.maxPemBytes + ? limitMessage("This key file", health.maxPemBytes) + : null; + const safeInspectionError = inspectionError ? "The private key could not be inspected." : null; + + const readinessReason = useMemo(() => { + if (!capability.available) return capability.reason; + if (!file) return "Choose an encrypted file."; + if (fileError) return fileError; + if (!privateKey) return "Choose a private key."; + if (keyError) return keyError; + if (inspecting) return "Inspecting private key."; + if (safeInspectionError) return safeInspectionError; + if (!inspection?.ok) return "The private key could not be inspected."; + if (inspection.keyInfo.key_type !== "private") return "A private key is required to decrypt a file."; + if (!password) return "Enter the private key password."; + if (!outputFilename.trim()) return "Enter an output filename."; + return null; + }, [ + capability.available, + capability.reason, + file, + fileError, + inspection, + inspecting, + keyError, + outputFilename, + password, + privateKey, + safeInspectionError + ]); + const canDecrypt = !readinessReason && !busy; + const phase = deriveWorkflowPhase({ + ready: Boolean(canDecrypt), + complete: Boolean(completedFilename) + }); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + requestIdRef.current += 1; + inFlightRef.current = false; + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + }; + }, []); + + function clearOutcome() { + requestIdRef.current += 1; + setError(null); + setCompletedFilename(null); + } + + function selectFile(nextFile: File | null) { + if (busy) return; + setFile(nextFile); + setOutputFilename(suggestedDecryptedName(nextFile)); + clearOutcome(); + } + + function selectPrivateKey(nextFile: File | null) { + if (busy) return; + setPrivateKey(nextFile); + clearOutcome(); + } + + function updateOutputFilename(value: string) { + if (busy) return; + setOutputFilename(value); + clearOutcome(); + } + + function updatePassword(value: string) { + if (busy) return; + setPassword(value); + clearOutcome(); + } + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!canDecrypt || !file || !privateKey || inFlightRef.current) return; + + const requestId = requestIdRef.current + 1; + const controller = new AbortController(); + requestIdRef.current = requestId; + inFlightRef.current = true; + abortControllerRef.current = controller; + setBusy(true); + setError(null); + setCompletedFilename(null); + + try { + const result = await decrypt(file, privateKey, password, outputFilename.trim(), controller.signal); + if (!mountedRef.current || requestId !== requestIdRef.current) return; + setPassword(""); + try { + save(result); + } catch { + setError("The file was decrypted, but the download could not start. Try decrypting it again."); + return; + } + setCompletedFilename(result.filename); + } catch (caught: unknown) { + if (!mountedRef.current || requestId !== requestIdRef.current) return; + if (controller.signal.aborted || isAbortError(caught)) return; + setError(decryptionError(caught)); + } finally { + if (requestId === requestIdRef.current) inFlightRef.current = false; + if (abortControllerRef.current === controller) abortControllerRef.current = null; + if (mountedRef.current && requestId === requestIdRef.current) setBusy(false); + } + } + + const keyClassification = !inspection + ? null + : inspection.keyInfo.key_type === "private" + ? "Supported encrypted private key; match not yet verified" + : "Public key — not valid for decryption"; + + return ( + + {capability.available && ( +
+ + + + {inspecting && Checking safe key metadata locally.} + {safeInspectionError && {safeInspectionError}} + +
+

Review

+
+
+
Encrypted file
+
{file ? file.name : "Not selected"}
+
+
+
Size
+
{file ? formatBytes(file.size) : "Not selected"}
+
+
+
Private key
+
{keyClassification ?? (privateKey ? "Awaiting inspection" : "Not selected")}
+
+
+ +
+ + updateOutputFilename(event.target.value)} + type="text" + value={outputFilename} + /> +
+ {readinessReason && !busy && readinessReason !== fileError && readinessReason !== keyError && readinessReason !== safeInspectionError && ( +

{readinessReason}

+ )} +
+ + + Decrypt file + + + )} + + {error && {error}} + {completedFilename && ( + + Saved {completedFilename}. + + )} + + +
+
+
Key format
+
{keyFormat(inspection)}
+
+
+
Key KDF
+
{inspection?.keyInfo.private_key_kdf ?? "Not available"}
+
+
+
Hybrid suite
+
{inspection?.keyInfo.kem ?? health.kem}
+
+
+
+
+ ); +} diff --git a/web/src/features/encrypt/EncryptWorkflow.test.tsx b/web/src/features/encrypt/EncryptWorkflow.test.tsx new file mode 100644 index 0000000..e59bc0a --- /dev/null +++ b/web/src/features/encrypt/EncryptWorkflow.test.tsx @@ -0,0 +1,238 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ApiError } from "../../api"; +import { READY_HEALTH } from "../../test/fixtures"; +import { EncryptWorkflow } from "./EncryptWorkflow"; + +function publicKeyInspection() { + return { + ok: true, + keyInfo: { kem: READY_HEALTH.kem, key_type: "public" as const }, + display: { "Key Type": "Public", Algorithm: READY_HEALTH.kem } + }; +} + +async function prepareEncryption(user: ReturnType) { + await user.upload(screen.getByLabelText("File to encrypt"), new File(["report"], "report.pdf")); + await user.upload(screen.getByLabelText("Recipient public key"), new File(["PEM"], "recipient.pem")); + await screen.findByText("Compatible public key"); +} + +describe("EncryptWorkflow", () => { + it("encrypts with a compatible public key and saves the returned file", async () => { + const inspect = vi.fn().mockResolvedValue(publicKeyInspection()); + const encrypt = vi.fn().mockResolvedValue({ + filename: "report_encrypted.pqc", + blob: new Blob(["ciphertext"]) + }); + const save = vi.fn(); + const user = userEvent.setup(); + + render(); + + await user.upload(screen.getByLabelText("File to encrypt"), new File(["report"], "report.pdf")); + await user.upload( + screen.getByLabelText("Recipient public key"), + new File(["PEM"], "recipient.pem", { type: "application/x-pem-file" }) + ); + + expect(await screen.findByText("Compatible public key")).toBeVisible(); + expect(screen.getByLabelText("Output filename")).toHaveValue("report_encrypted.pqc"); + + await user.click(screen.getByRole("button", { name: "Encrypt file" })); + + await waitFor(() => + expect(encrypt).toHaveBeenCalledWith( + expect.objectContaining({ name: "report.pdf" }), + expect.objectContaining({ name: "recipient.pem" }), + "report_encrypted.pqc", + expect.any(AbortSignal) + ) + ); + expect(save).toHaveBeenCalledWith(expect.objectContaining({ filename: "report_encrypted.pqc" })); + expect(await screen.findByText(/saved report_encrypted\.pqc/i)).toBeVisible(); + expect(screen.getByText("New file format version").nextElementSibling).toHaveTextContent("4"); + }); + + it("reports a browser download failure separately from a service failure", async () => { + const inspect = vi.fn().mockResolvedValue(publicKeyInspection()); + const encrypt = vi.fn().mockResolvedValue({ + filename: "report_encrypted.pqc", + blob: new Blob(["ciphertext"]) + }); + const save = vi.fn(() => { + throw new TypeError("browser download failed"); + }); + const user = userEvent.setup(); + + render(); + await prepareEncryption(user); + await user.click(screen.getByRole("button", { name: "Encrypt file" })); + + expect( + await screen.findByText("The file was encrypted, but the download could not start. Try encrypting it again.") + ).toBeVisible(); + expect(screen.queryByText(/could not reach the local service/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/saved report_encrypted\.pqc/i)).not.toBeInTheDocument(); + }); + + it("keeps encryption disabled with a textual reason when the plaintext is oversized", async () => { + const health = { ...READY_HEALTH, maxFileBytes: 3 }; + const encrypt = vi.fn(); + const user = userEvent.setup(); + + render(); + await user.upload(screen.getByLabelText("File to encrypt"), new File(["four"], "report.pdf")); + + expect(screen.getByRole("button", { name: "Encrypt file" })).toBeDisabled(); + expect(screen.getByText(/exceeds the 3 byte limit/i)).toBeVisible(); + expect(encrypt).not.toHaveBeenCalled(); + }); + + it("keeps encryption disabled when inspection finds a private key", async () => { + const inspect = vi.fn().mockResolvedValue({ + ok: true, + keyInfo: { + kem: READY_HEALTH.kem, + key_type: "private" + }, + display: { + "Key Type": "Encrypted private key", + Algorithm: READY_HEALTH.kem + } + }); + const user = userEvent.setup(); + + render(); + await user.upload(screen.getByLabelText("File to encrypt"), new File(["report"], "report.pdf")); + await user.upload(screen.getByLabelText("Recipient public key"), new File(["PEM"], "recipient.pem")); + + expect(await screen.findByText(/a public key is required/i)).toBeVisible(); + expect(screen.getByRole("button", { name: "Encrypt file" })).toBeDisabled(); + }); + + it("does not submit a second encryption request while one is in progress", async () => { + let resolveEncryption: (value: { filename: string; blob: Blob }) => void; + const inspect = vi.fn().mockResolvedValue(publicKeyInspection()); + const encrypt = vi.fn( + (_file: File, _publicKey: File, _outputFilename: string, _signal?: AbortSignal) => + new Promise<{ filename: string; blob: Blob }>((resolve) => { + resolveEncryption = resolve; + }) + ); + const user = userEvent.setup(); + + render(); + await user.upload(screen.getByLabelText("File to encrypt"), new File(["report"], "report.pdf")); + await user.upload(screen.getByLabelText("Recipient public key"), new File(["PEM"], "recipient.pem")); + await screen.findByText("Compatible public key"); + + await user.click(screen.getByRole("button", { name: "Encrypt file" })); + await user.click(screen.getByRole("button", { name: "Encrypting locally" })); + + expect(encrypt).toHaveBeenCalledTimes(1); + expect(screen.getByLabelText("File to encrypt")).toBeDisabled(); + expect(screen.getByLabelText("Recipient public key")).toBeDisabled(); + expect(screen.getByLabelText("Output filename")).toBeDisabled(); + expect(encrypt.mock.calls[0]?.[3]).toBeInstanceOf(AbortSignal); + resolveEncryption!({ filename: "report_encrypted.pqc", blob: new Blob(["ciphertext"]) }); + expect(await screen.findByText(/saved report_encrypted\.pqc/i)).toBeVisible(); + }); + + it("does not accept a public key from a different hybrid suite", async () => { + const inspect = vi.fn().mockResolvedValue({ + ok: true, + keyInfo: { kem: "Kyber768", key_type: "public" }, + display: { "Key Type": "Public", Algorithm: "Kyber768" } + }); + const user = userEvent.setup(); + + render(); + await user.upload(screen.getByLabelText("File to encrypt"), new File(["report"], "report.pdf")); + await user.upload(screen.getByLabelText("Recipient public key"), new File(["PEM"], "recipient.pem")); + + expect(await screen.findByText(/encryption requires ML-KEM-768\+X25519-v2/i)).toBeVisible(); + expect(screen.getByRole("button", { name: "Encrypt file" })).toBeDisabled(); + }); + + it("aborts an in-flight encryption request on unmount", async () => { + const inspect = vi.fn().mockResolvedValue(publicKeyInspection()); + const encrypt = vi.fn( + (_file: File, _publicKey: File, _outputFilename: string, _signal?: AbortSignal) => + new Promise<{ filename: string; blob: Blob }>(() => undefined) + ); + const user = userEvent.setup(); + const { unmount } = render( + + ); + + await prepareEncryption(user); + await user.click(screen.getByRole("button", { name: "Encrypt file" })); + await waitFor(() => expect(encrypt).toHaveBeenCalledTimes(1)); + const signal = encrypt.mock.calls[0]?.[3] as AbortSignal; + + unmount(); + + expect(signal.aborted).toBe(true); + }); + + it("preserves backend guidance and distinguishes network failure", async () => { + const inspect = vi.fn().mockResolvedValue(publicKeyInspection()); + const user = userEvent.setup(); + const backendFailure = vi.fn().mockRejectedValue( + new ApiError(503, "backend_unavailable", "Post-quantum backend is not ready.") + ); + const { unmount } = render( + + ); + + await prepareEncryption(user); + await user.click(screen.getByRole("button", { name: "Encrypt file" })); + expect(await screen.findByText("Post-quantum backend is not ready.")).toBeVisible(); + + unmount(); + const networkFailure = vi.fn().mockRejectedValue(new TypeError("fetch failed: private socket detail")); + render(); + await prepareEncryption(user); + await user.click(screen.getByRole("button", { name: "Encrypt file" })); + + expect(await screen.findByText("Could not reach the local service. Check that it is running and try again.")).toBeVisible(); + expect(screen.queryByText(/private socket detail/i)).not.toBeInTheDocument(); + }); + + it("preserves safe recipient-key guidance from the local API", async () => { + const inspect = vi.fn().mockResolvedValue(publicKeyInspection()); + const encrypt = vi.fn().mockRejectedValue( + new ApiError( + 400, + "legacy_public_key", + "Generate a new ML-KEM-768+X25519-v2 public key for encryption." + ) + ); + const user = userEvent.setup(); + + render(); + await prepareEncryption(user); + await user.click(screen.getByRole("button", { name: "Encrypt file" })); + + expect( + await screen.findByText("Generate a new ML-KEM-768+X25519-v2 public key for encryption.") + ).toBeVisible(); + }); + + it("does not render an AbortError as an encryption failure", async () => { + const inspect = vi.fn().mockResolvedValue(publicKeyInspection()); + const aborted = new Error("request aborted"); + aborted.name = "AbortError"; + const encrypt = vi.fn().mockRejectedValue(aborted); + const user = userEvent.setup(); + + render(); + await prepareEncryption(user); + await user.click(screen.getByRole("button", { name: "Encrypt file" })); + + await waitFor(() => expect(screen.getByRole("button", { name: "Encrypt file" })).toBeEnabled()); + expect(screen.queryByText("Encryption failed")).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/features/encrypt/EncryptWorkflow.tsx b/web/src/features/encrypt/EncryptWorkflow.tsx new file mode 100644 index 0000000..d24e06c --- /dev/null +++ b/web/src/features/encrypt/EncryptWorkflow.tsx @@ -0,0 +1,268 @@ +import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; +import { + encryptFile, + type DownloadResult, + type EncryptFileOperation, + type Health, + type InspectKeyOperation +} from "../../api"; +import { isAbortError, safeOperationError } from "../../api/errors"; +import { ActionButton } from "../../components/ActionButton"; +import { FilePicker } from "../../components/FilePicker"; +import { Notice } from "../../components/Notice"; +import { TechnicalDetails } from "../../components/TechnicalDetails"; +import { WorkflowLayout } from "../../components/WorkflowLayout"; +import { useKeyInspection } from "../../hooks/useKeyInspection"; +import { downloadBlob } from "../../lib/download"; +import { suggestedEncryptedName } from "../../lib/filenames"; +import { formatBytes } from "../../lib/format"; +import { deriveWorkflowPhase } from "../../lib/workflow"; + +export type EncryptWorkflowProps = { + health: Health; + inspect?: InspectKeyOperation; + encrypt?: EncryptFileOperation; + save?: (result: DownloadResult) => void; +}; + +function limitMessage(label: string, maxBytes: number): string { + return `${label} exceeds the ${maxBytes.toLocaleString()} byte limit.`; +} + +export function EncryptWorkflow({ + health, + inspect, + encrypt = encryptFile, + save = downloadBlob +}: EncryptWorkflowProps) { + const [file, setFile] = useState(null); + const [publicKey, setPublicKey] = useState(null); + const [outputFilename, setOutputFilename] = useState(""); + const [error, setError] = useState(null); + const [completedFilename, setCompletedFilename] = useState(null); + const [busy, setBusy] = useState(false); + const mountedRef = useRef(true); + const requestIdRef = useRef(0); + const inFlightRef = useRef(false); + const abortControllerRef = useRef(null); + const capability = health.capabilities.encrypt; + const { result: inspection, error: inspectionError, loading: inspecting } = useKeyInspection( + capability.available ? publicKey : null, + health.maxPemBytes, + inspect + ); + const fileError = file && file.size > health.maxFileBytes ? limitMessage("This file", health.maxFileBytes) : null; + const keyError = publicKey && publicKey.size > health.maxPemBytes ? limitMessage("This key file", health.maxPemBytes) : null; + const safeInspectionError = inspectionError ? "The recipient key could not be inspected." : null; + const compatiblePublicKey = Boolean( + inspection?.ok && inspection.keyInfo.key_type === "public" && inspection.keyInfo.kem === health.kem + ); + + const readinessReason = useMemo(() => { + if (!capability.available) return capability.reason; + if (!file) return "Choose a file to encrypt."; + if (fileError) return fileError; + if (!publicKey) return "Choose the recipient's public key."; + if (keyError) return keyError; + if (inspecting) return "Inspecting recipient key."; + if (safeInspectionError) return safeInspectionError; + if (!inspection?.ok) return "The recipient key could not be inspected."; + if (inspection.keyInfo.key_type !== "public") return "A public key is required to encrypt a file."; + if (inspection.keyInfo.kem !== health.kem) { + return `This public key uses ${inspection.keyInfo.kem}; encryption requires ${health.kem}.`; + } + if (!outputFilename.trim()) return "Enter an output filename."; + return null; + }, [ + capability.available, + capability.reason, + file, + fileError, + health.kem, + inspection, + inspecting, + keyError, + outputFilename, + publicKey, + safeInspectionError + ]); + const canEncrypt = !readinessReason && !busy; + const phase = deriveWorkflowPhase({ + ready: Boolean(canEncrypt), + complete: Boolean(completedFilename) + }); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + requestIdRef.current += 1; + inFlightRef.current = false; + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + }; + }, []); + + function clearOutcome() { + requestIdRef.current += 1; + setError(null); + setCompletedFilename(null); + } + + function selectFile(nextFile: File | null) { + if (busy) return; + setFile(nextFile); + setOutputFilename(suggestedEncryptedName(nextFile)); + clearOutcome(); + } + + function selectPublicKey(nextFile: File | null) { + if (busy) return; + setPublicKey(nextFile); + clearOutcome(); + } + + function updateOutputFilename(value: string) { + if (busy) return; + setOutputFilename(value); + clearOutcome(); + } + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!canEncrypt || !file || !publicKey || inFlightRef.current) return; + + const requestId = requestIdRef.current + 1; + const controller = new AbortController(); + requestIdRef.current = requestId; + inFlightRef.current = true; + abortControllerRef.current = controller; + setBusy(true); + setError(null); + setCompletedFilename(null); + + try { + const result = await encrypt(file, publicKey, outputFilename.trim(), controller.signal); + if (!mountedRef.current || requestId !== requestIdRef.current) return; + try { + save(result); + } catch { + setError("The file was encrypted, but the download could not start. Try encrypting it again."); + return; + } + setCompletedFilename(result.filename); + } catch (caught: unknown) { + if (!mountedRef.current || requestId !== requestIdRef.current) return; + if (controller.signal.aborted || isAbortError(caught)) return; + setError(safeOperationError(caught, "Could not encrypt this file. Confirm the recipient key and try again.")); + } finally { + if (requestId === requestIdRef.current) inFlightRef.current = false; + if (abortControllerRef.current === controller) abortControllerRef.current = null; + if (mountedRef.current && requestId === requestIdRef.current) setBusy(false); + } + } + + const keyClassification = !inspection + ? null + : compatiblePublicKey + ? "Compatible public key" + : inspection.keyInfo.key_type === "private" + ? "Private key — not valid for encryption" + : "Public key — not compatible"; + + return ( + + {capability.available && ( +
+ + + + {inspecting && Checking safe key metadata locally.} + {safeInspectionError && {safeInspectionError}} + +
+

Review

+
+
+
File
+
{file ? file.name : "Not selected"}
+
+
+
Size
+
{file ? formatBytes(file.size) : "Not selected"}
+
+
+
Recipient key
+
{keyClassification ?? (publicKey ? "Awaiting inspection" : "Not selected")}
+
+
+
+ + updateOutputFilename(event.target.value)} + type="text" + value={outputFilename} + /> +
+ {readinessReason && !busy && readinessReason !== fileError && readinessReason !== keyError && readinessReason !== safeInspectionError && ( +

{readinessReason}

+ )} +
+ + + Encrypt file + + + )} + + {error && {error}} + {completedFilename && ( + + Saved {completedFilename}. + + )} + + +
+
+
Hybrid suite
+
{health.kem}
+
+
+
DEM
+
{health.dem}
+
+
+
New file format version
+
{health.formatVersion}
+
+
+
+
+ ); +} diff --git a/web/src/features/generate/GenerateKeysWorkflow.test.tsx b/web/src/features/generate/GenerateKeysWorkflow.test.tsx new file mode 100644 index 0000000..d357c4a --- /dev/null +++ b/web/src/features/generate/GenerateKeysWorkflow.test.tsx @@ -0,0 +1,257 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { ApiError, type GeneratedKeys } from "../../api"; +import { READY_HEALTH } from "../../test/fixtures"; +import { GenerateKeysWorkflow } from "./GenerateKeysWorkflow"; + +function generatedKeys(overrides: Partial = {}): GeneratedKeys { + return { + ok: true, + kem: "ML-KEM-768+X25519-v2", + publicPem: "PUBLIC-PEM", + privatePem: "-----BEGIN PQC PRIVATE KEY-----\nPQC-Key-Format: 3\nENCRYPTED-PRIVATE-PEM\n-----END PQC PRIVATE KEY-----", + publicFilename: "recipient-public.pem", + privateFilename: "recipient-private.pem", + ...overrides + }; +} + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function enterValidPasswords(user: ReturnType) { + await user.type(screen.getByLabelText("Private key password"), "correct horse battery staple"); + await user.type(screen.getByLabelText("Confirm private key password"), "correct horse battery staple"); +} + +describe("GenerateKeysWorkflow", () => { + it("keeps generation disabled for a server-blocked common password", async () => { + const user = userEvent.setup(); + + render(); + await user.type(screen.getByLabelText("Private key password"), "password12345678"); + await user.type(screen.getByLabelText("Confirm private key password"), "password12345678"); + + expect(screen.getByText("Not a common password").closest("li")).not.toHaveClass("password-policy-met"); + expect(screen.getByRole("button", { name: "Generate key pair" })).toBeDisabled(); + }); + + it("keeps generated key material in memory until the user explicitly clears it", async () => { + const generate = vi.fn().mockResolvedValue(generatedKeys()); + const onSensitiveResultChange = vi.fn(); + const user = userEvent.setup(); + + render( + + ); + + const generateButton = screen.getByRole("button", { name: "Generate key pair" }); + expect(generateButton).toBeDisabled(); + + await enterValidPasswords(user); + expect(generateButton).toBeEnabled(); + + await user.click(generateButton); + + await waitFor(() => + expect(generate).toHaveBeenCalledWith("correct horse battery staple", expect.any(AbortSignal)) + ); + expect(screen.queryByLabelText("Private key password")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Confirm private key password")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Download public key" })).toBeVisible(); + expect(screen.getByRole("button", { name: "Download encrypted private key" })).toBeVisible(); + expect(screen.queryByText("PUBLIC-PEM")).not.toBeInTheDocument(); + expect(screen.queryByText("ENCRYPTED-PRIVATE-PEM")).not.toBeInTheDocument(); + expect(onSensitiveResultChange).toHaveBeenCalledWith(true); + expect(screen.getByText("Private key format version").nextElementSibling).toHaveTextContent("3"); + + await user.click(screen.getByRole("button", { name: "Clear generated keys" })); + + expect(screen.queryByRole("button", { name: "Download public key" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Download encrypted private key" })).not.toBeInTheDocument(); + expect(onSensitiveResultChange).toHaveBeenLastCalledWith(false); + }); + + it("requires clearing generated keys before another key pair can be requested", async () => { + const secondRequest = new Promise(() => undefined); + const generate = vi.fn() + .mockResolvedValueOnce(generatedKeys()) + .mockReturnValueOnce(secondRequest); + const onSensitiveResultChange = vi.fn(); + const user = userEvent.setup(); + + render( + + ); + + await enterValidPasswords(user); + await user.click(screen.getByRole("button", { name: "Generate key pair" })); + await waitFor(() => expect(generate).toHaveBeenCalledTimes(1)); + expect(screen.getByRole("button", { name: "Download public key" })).toBeVisible(); + expect(onSensitiveResultChange).toHaveBeenLastCalledWith(true); + + expect(screen.queryByLabelText("Private key password")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Generate key pair" })).not.toBeInTheDocument(); + expect(generate).toHaveBeenCalledTimes(1); + expect(screen.getByRole("button", { name: "Download encrypted private key" })).toBeVisible(); + expect(onSensitiveResultChange).toHaveBeenLastCalledWith(true); + + await user.click(screen.getByRole("button", { name: "Clear generated keys" })); + + expect(screen.getByLabelText("Private key password")).toBeVisible(); + expect(screen.getByRole("button", { name: "Generate key pair" })).toBeDisabled(); + expect(onSensitiveResultChange).toHaveBeenLastCalledWith(false); + }); + + it("notifies the shell to clear sensitive result state when unmounted", () => { + const onSensitiveResultChange = vi.fn(); + const { unmount } = render( + + ); + + unmount(); + + expect(onSensitiveResultChange).toHaveBeenCalledWith(false); + }); + + it("does not reassert sensitive state when an in-flight request resolves after unmount", async () => { + let resolveGeneration: (value: ReturnType) => void; + const generate = vi.fn( + (_password: string, _signal?: AbortSignal) => + new Promise>((resolve) => { + resolveGeneration = resolve; + }) + ); + const onSensitiveResultChange = vi.fn(); + const user = userEvent.setup(); + const { unmount } = render( + + ); + + await enterValidPasswords(user); + await user.click(screen.getByRole("button", { name: "Generate key pair" })); + await waitFor(() => expect(generate).toHaveBeenCalledTimes(1)); + const signal = generate.mock.calls[0]?.[1] as AbortSignal; + + unmount(); + expect(signal.aborted).toBe(true); + resolveGeneration!(generatedKeys()); + await Promise.resolve(); + + expect(onSensitiveResultChange).not.toHaveBeenCalledWith(true); + expect(onSensitiveResultChange).toHaveBeenLastCalledWith(false); + }); + + it("locks password controls while generation is in flight", async () => { + const request = deferred(); + const generate = vi.fn().mockReturnValue(request.promise); + const user = userEvent.setup(); + + render(); + await enterValidPasswords(user); + await user.click(screen.getByRole("button", { name: "Generate key pair" })); + await waitFor(() => expect(generate).toHaveBeenCalledTimes(1)); + + expect(screen.getByLabelText("Private key password")).toBeDisabled(); + expect(screen.getByLabelText("Confirm private key password")).toBeDisabled(); + for (const button of screen.getAllByRole("button", { name: "Show password" })) { + expect(button).toBeDisabled(); + } + expect(screen.getByRole("button", { name: "Generating key pair" })).toBeDisabled(); + + request.resolve(generatedKeys()); + expect(await screen.findByRole("button", { name: "Download public key" })).toBeVisible(); + }); + + it("preserves safe backend error categories and distinguishes network failure", async () => { + const backendFailure = vi.fn().mockRejectedValue( + new ApiError(503, "backend_unavailable", "Post-quantum backend is not ready.") + ); + const user = userEvent.setup(); + const { unmount } = render(); + + await enterValidPasswords(user); + await user.click(screen.getByRole("button", { name: "Generate key pair" })); + expect(await screen.findByText("Post-quantum backend is not ready.")).toBeVisible(); + + unmount(); + const networkFailure = vi.fn().mockRejectedValue(new TypeError("fetch failed: private socket detail")); + render(); + await enterValidPasswords(user); + await user.click(screen.getByRole("button", { name: "Generate key pair" })); + + expect(await screen.findByText("Could not reach the local service. Check that it is running and try again.")).toBeVisible(); + expect(screen.queryByText(/private socket detail/i)).not.toBeInTheDocument(); + }); + + it("preserves safe server password-policy guidance", async () => { + const generate = vi.fn().mockRejectedValue( + new ApiError(400, "weak_password", "Private-key password is too common.") + ); + const user = userEvent.setup(); + + render(); + await enterValidPasswords(user); + await user.click(screen.getByRole("button", { name: "Generate key pair" })); + + expect(await screen.findByText("Private-key password is too common.")).toBeVisible(); + }); + + it("does not render an AbortError as a key-generation failure", async () => { + const aborted = new Error("request aborted"); + aborted.name = "AbortError"; + const generate = vi.fn().mockRejectedValue(aborted); + const user = userEvent.setup(); + + render(); + await enterValidPasswords(user); + await user.click(screen.getByRole("button", { name: "Generate key pair" })); + + await waitFor(() => expect(screen.getByRole("button", { name: "Generate key pair" })).toBeEnabled()); + expect(screen.queryByText("Key generation failed")).not.toBeInTheDocument(); + }); + + it("does not announce or expose an unsuccessful generation response", async () => { + const generate = vi.fn().mockResolvedValue(generatedKeys({ ok: false })); + const onSensitiveResultChange = vi.fn(); + const user = userEvent.setup(); + + render( + + ); + + await enterValidPasswords(user); + await user.click(screen.getByRole("button", { name: "Generate key pair" })); + + expect(await screen.findByText("Could not generate keys. Check the password requirements and try again.")).toBeVisible(); + expect(screen.queryByRole("button", { name: "Download public key" })).not.toBeInTheDocument(); + expect(onSensitiveResultChange).not.toHaveBeenCalledWith(true); + }); +}); diff --git a/web/src/features/generate/GenerateKeysWorkflow.tsx b/web/src/features/generate/GenerateKeysWorkflow.tsx new file mode 100644 index 0000000..af68d4e --- /dev/null +++ b/web/src/features/generate/GenerateKeysWorkflow.tsx @@ -0,0 +1,223 @@ +import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; +import { generateKeys, type GenerateKeysOperation, type GeneratedKeys, type Health } from "../../api"; +import { isAbortError, safeOperationError } from "../../api/errors"; +import { ActionButton } from "../../components/ActionButton"; +import { Notice } from "../../components/Notice"; +import { PasswordField } from "../../components/PasswordField"; +import { TechnicalDetails } from "../../components/TechnicalDetails"; +import { WorkflowLayout } from "../../components/WorkflowLayout"; +import { downloadText } from "../../lib/download"; +import { deriveWorkflowPhase } from "../../lib/workflow"; +import { passwordPolicyChecks } from "./passwordPolicy"; + +export type GenerateKeysWorkflowProps = { + health: Health; + generate?: GenerateKeysOperation; + onSensitiveResultChange?: (present: boolean) => void; +}; + +type GeneratedKeyState = Omit & { + publicPem: string | null; + privatePem: string | null; +}; + +function privateKeyFormatVersion(privatePem: string | null | undefined): string | null { + if (!privatePem) return null; + return privatePem.match(/^PQC-Key-Format:\s*(\d+)\s*$/m)?.[1] ?? "Not declared"; +} + +export function GenerateKeysWorkflow({ health, generate = generateKeys, onSensitiveResultChange }: GenerateKeysWorkflowProps) { + const [password, setPassword] = useState(""); + const [confirmation, setConfirmation] = useState(""); + const [generatedKeys, setGeneratedKeys] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const mountedRef = useRef(true); + const requestIdRef = useRef(0); + const inFlightRef = useRef(false); + const abortControllerRef = useRef(null); + const sensitiveResultChangeRef = useRef(onSensitiveResultChange); + const capability = health.capabilities.generate; + const checks = useMemo( + () => passwordPolicyChecks(password, confirmation, health.passwordPolicy), + [confirmation, health.passwordPolicy, password] + ); + const policySatisfied = checks.every((check) => check.met); + const hasGeneratedKeys = Boolean(generatedKeys?.publicPem && generatedKeys.privatePem); + const canGenerate = capability.available && policySatisfied && !busy && !hasGeneratedKeys; + const phase = deriveWorkflowPhase({ + ready: capability.available && policySatisfied, + complete: hasGeneratedKeys + }); + + useEffect(() => { + sensitiveResultChangeRef.current = onSensitiveResultChange; + }, [onSensitiveResultChange]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + requestIdRef.current += 1; + inFlightRef.current = false; + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + sensitiveResultChangeRef.current?.(false); + }; + }, []); + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!canGenerate || hasGeneratedKeys || inFlightRef.current) return; + + const requestId = requestIdRef.current + 1; + const controller = new AbortController(); + requestIdRef.current = requestId; + inFlightRef.current = true; + abortControllerRef.current = controller; + setBusy(true); + setError(null); + + try { + const result = await generate(password, controller.signal); + if (!mountedRef.current || requestId !== requestIdRef.current) return; + if (!result.ok) { + setError("Could not generate keys. Check the password requirements and try again."); + return; + } + setGeneratedKeys(result); + setPassword(""); + setConfirmation(""); + sensitiveResultChangeRef.current?.(true); + } catch (caught: unknown) { + if (!mountedRef.current || requestId !== requestIdRef.current) return; + if (controller.signal.aborted || isAbortError(caught)) return; + setError(safeOperationError(caught, "Could not generate keys. Check the password requirements and try again.")); + } finally { + if (requestId === requestIdRef.current) inFlightRef.current = false; + if (abortControllerRef.current === controller) abortControllerRef.current = null; + if (mountedRef.current && requestId === requestIdRef.current) { + setBusy(false); + } + } + } + + function clearGeneratedKeys() { + setGeneratedKeys((current) => + current + ? { + ...current, + publicPem: null, + privatePem: null + } + : null + ); + sensitiveResultChangeRef.current?.(false); + } + + return ( + + + Your private key password cannot be recovered. Save it in a secure password manager before generating keys. + + + {capability.available && !hasGeneratedKeys && ( +
+ { + if (!busy) setPassword(value); + }} + value={password} + /> + { + if (!busy) setConfirmation(value); + }} + value={confirmation} + /> +
    + {checks.map((check) => ( +
  • + + {check.label} +
  • + ))} +
+ + Generate key pair + + + )} + + {error && {error}} + + {hasGeneratedKeys && generatedKeys && ( +
+ + Save both files now. The public key is safe to share; keep the encrypted private key and password secure. + +
+ + +
+ + Clear generated keys + +
+ )} + + +
+
+
Hybrid suite
+
{health.kem}
+
+ {generatedKeys?.privatePem && ( +
+
Private key format version
+
{privateKeyFormatVersion(generatedKeys.privatePem)}
+
+ )} +
+
Password KDF
+
scrypt
+
+
+
Password requirements
+
{health.passwordPolicy.minChars} or more characters and {health.passwordPolicy.minUniqueChars} or more unique characters
+
+
+
+
+ ); +} diff --git a/web/src/features/generate/passwordPolicy.test.ts b/web/src/features/generate/passwordPolicy.test.ts new file mode 100644 index 0000000..5ab7f72 --- /dev/null +++ b/web/src/features/generate/passwordPolicy.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { passwordPolicyChecks } from "./passwordPolicy"; + +describe("passwordPolicyChecks", () => { + it("reports the server-owned policy requirements and confirmation state", () => { + expect( + passwordPolicyChecks( + "correct horse battery staple", + "correct horse battery staple", + { minChars: 16, minUniqueChars: 5 } + ) + ).toEqual([ + { label: "16 or more characters", met: true }, + { label: "5 or more unique characters", met: true }, + { label: "Not a common password", met: true }, + { label: "Passwords match", met: true } + ]); + }); + + it.each([ + "passwordpassword", + "password12345678", + "qwertyuiopasdfgh", + " PassWord 12345678 ", + "Pass\u0085word12345678", + "Pass\u001cword12345678" + ])("matches the server normalization and rejects common password %j", (password) => { + const checks = passwordPolicyChecks(password, password, { minChars: 16, minUniqueChars: 5 }); + + expect(checks.find((check) => check.label === "Not a common password")).toEqual({ + label: "Not a common password", + met: false + }); + }); +}); diff --git a/web/src/features/generate/passwordPolicy.ts b/web/src/features/generate/passwordPolicy.ts new file mode 100644 index 0000000..c2b8eea --- /dev/null +++ b/web/src/features/generate/passwordPolicy.ts @@ -0,0 +1,44 @@ +import type { Health } from "../../api"; + +export type PasswordPolicyCheck = { + label: string; + met: boolean; +}; + +// Keep this usability check synchronized with crypto_core.COMMON_WEAK_PASSWORDS; the server remains authoritative. +const COMMON_WEAK_PASSWORDS = new Set([ + "passwordpassword", + "password12345678", + "qwertyuiopasdfgh" +]); +const PYTHON_SPLIT_WHITESPACE = /[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/gu; + +function isCommonPassword(password: string): boolean { + const normalized = password.toLowerCase().replace(PYTHON_SPLIT_WHITESPACE, ""); + return COMMON_WEAK_PASSWORDS.has(normalized); +} + +export function passwordPolicyChecks( + password: string, + confirmation: string, + policy: Health["passwordPolicy"] +): PasswordPolicyCheck[] { + return [ + { + label: `${policy.minChars} or more characters`, + met: password.length >= policy.minChars + }, + { + label: `${policy.minUniqueChars} or more unique characters`, + met: new Set(password).size >= policy.minUniqueChars + }, + { + label: "Not a common password", + met: password.length > 0 && !isCommonPassword(password) + }, + { + label: "Passwords match", + met: password.length > 0 && password === confirmation + } + ]; +} diff --git a/web/src/features/inspect/InspectKeyWorkflow.test.tsx b/web/src/features/inspect/InspectKeyWorkflow.test.tsx new file mode 100644 index 0000000..9281368 --- /dev/null +++ b/web/src/features/inspect/InspectKeyWorkflow.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { READY_HEALTH } from "../../test/fixtures"; +import { InspectKeyWorkflow } from "./InspectKeyWorkflow"; + +describe("InspectKeyWorkflow", () => { + it("shows a safe key summary without rendering uploaded key material", async () => { + const inspect = vi.fn().mockResolvedValue({ + ok: true, + keyInfo: { + kem: "ML-KEM-768+X25519-v2", + key_type: "public" + }, + display: { + "Key Type": "Public", + Algorithm: "ML-KEM-768+X25519-v2" + } + }); + + const user = userEvent.setup(); + render(); + await user.upload( + screen.getByLabelText("Key file"), + new File(["PEM"], "recipient.pem", { type: "application/x-pem-file" }) + ); + + expect(await screen.findByText("Public key")).toBeVisible(); + await user.click(screen.getByText("Technical details")); + expect(screen.getByText("ML-KEM-768+X25519-v2")).toBeVisible(); + expect(screen.queryByText("PEM")).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/features/inspect/InspectKeyWorkflow.tsx b/web/src/features/inspect/InspectKeyWorkflow.tsx new file mode 100644 index 0000000..d6c2b91 --- /dev/null +++ b/web/src/features/inspect/InspectKeyWorkflow.tsx @@ -0,0 +1,68 @@ +import { useState } from "react"; +import type { Health, InspectKeyOperation, KeyInspectResult } from "../../api"; +import { FilePicker } from "../../components/FilePicker"; +import { Notice } from "../../components/Notice"; +import { TechnicalDetails } from "../../components/TechnicalDetails"; +import { WorkflowLayout } from "../../components/WorkflowLayout"; +import { useKeyInspection } from "../../hooks/useKeyInspection"; +import { deriveWorkflowPhase } from "../../lib/workflow"; + +export type InspectKeyWorkflowProps = { + health: Health; + inspect?: InspectKeyOperation; +}; + +function summaryLabel(result: KeyInspectResult): string { + return result.keyInfo.key_type === "public" ? "Public key" : "Encrypted private key"; +} + +export function InspectKeyWorkflow({ health, inspect }: InspectKeyWorkflowProps) { + const [file, setFile] = useState(null); + const capability = health.capabilities.inspect; + const { result, error, loading } = useKeyInspection( + capability.available ? file : null, + health.maxPemBytes, + inspect + ); + const phase = deriveWorkflowPhase({ ready: capability.available && Boolean(file) && !error, complete: Boolean(result) }); + + return ( + + {capability.available && ( + + )} + {loading && Reading supported key metadata locally.} + {result && ( +
+ + Supported key metadata was read locally. + + +
+ {Object.entries(result.display).map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+
+ )} +
+ ); +} diff --git a/web/src/hooks/useKeyInspection.test.tsx b/web/src/hooks/useKeyInspection.test.tsx new file mode 100644 index 0000000..44db334 --- /dev/null +++ b/web/src/hooks/useKeyInspection.test.tsx @@ -0,0 +1,131 @@ +import { act, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { InspectKeyOperation, KeyInspectResult } from "../api"; +import { useKeyInspection } from "./useKeyInspection"; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function result(kem: string): KeyInspectResult { + return { + ok: true, + keyInfo: { kem, key_type: "public" }, + display: { Algorithm: kem } + }; +} + +function InspectionProbe({ file, inspect }: { file: File | null; inspect: InspectKeyOperation }) { + const state = useKeyInspection(file, 1024, inspect); + return ( + + {state.result?.keyInfo.kem ?? "no result"}|{state.error ?? "no error"}|{state.loading ? "loading" : "idle"} + + ); +} + +describe("useKeyInspection", () => { + it("aborts the previous request when the selected file changes", () => { + const firstFile = new File(["first"], "first.pem", { type: "application/x-pem-file" }); + const secondFile = new File(["second"], "second.pem", { type: "application/x-pem-file" }); + let firstSignal: AbortSignal | undefined; + const inspect = vi.fn((file: File, signal?: AbortSignal) => { + if (file === firstFile) firstSignal = signal; + return new Promise(() => undefined); + }); + + const { rerender } = render(); + expect(screen.getByTestId("inspection-state")).toHaveTextContent("loading"); + + rerender(); + + expect(firstSignal?.aborted).toBe(true); + expect(inspect).toHaveBeenCalledWith(secondFile, expect.any(AbortSignal)); + }); + + it("does not render a completed result for a newly selected file", async () => { + const firstFile = new File(["first"], "first.pem", { type: "application/x-pem-file" }); + const secondFile = new File(["second"], "second.pem", { type: "application/x-pem-file" }); + const firstRequest = deferred(); + const secondRequest = deferred(); + const inspect = vi.fn((file: File) => (file === firstFile ? firstRequest.promise : secondRequest.promise)); + const { rerender } = render(); + + await act(async () => { + firstRequest.resolve(result("first-result")); + await firstRequest.promise; + }); + expect(screen.getByTestId("inspection-state")).toHaveTextContent("first-result"); + + rerender(); + + expect(screen.getByTestId("inspection-state")).not.toHaveTextContent("first-result"); + expect(screen.getByTestId("inspection-state")).toHaveTextContent("loading"); + }); + + it("ignores a previous request resolving after selection changes", async () => { + const firstFile = new File(["first"], "first.pem", { type: "application/x-pem-file" }); + const secondFile = new File(["second"], "second.pem", { type: "application/x-pem-file" }); + const firstRequest = deferred(); + const secondRequest = deferred(); + const inspect = vi.fn((file: File) => (file === firstFile ? firstRequest.promise : secondRequest.promise)); + const { rerender } = render(); + + rerender(); + await act(async () => { + firstRequest.resolve(result("first-result")); + await firstRequest.promise; + }); + + expect(screen.getByTestId("inspection-state")).not.toHaveTextContent("first-result"); + await act(async () => { + secondRequest.resolve(result("second-result")); + await secondRequest.promise; + }); + expect(screen.getByTestId("inspection-state")).toHaveTextContent("second-result"); + }); + + it("ignores a previous request rejecting after selection changes", async () => { + const firstFile = new File(["first"], "first.pem", { type: "application/x-pem-file" }); + const secondFile = new File(["second"], "second.pem", { type: "application/x-pem-file" }); + const firstRequest = deferred(); + const secondRequest = deferred(); + const inspect = vi.fn((file: File) => (file === firstFile ? firstRequest.promise : secondRequest.promise)); + const { rerender } = render(); + + rerender(); + await act(async () => { + firstRequest.reject(new Error("first error")); + await Promise.resolve(); + }); + + expect(screen.getByTestId("inspection-state")).not.toHaveTextContent("first error"); + await act(async () => { + secondRequest.resolve(result("second-result")); + await secondRequest.promise; + }); + expect(screen.getByTestId("inspection-state")).toHaveTextContent("second-result"); + }); + + it("rejects an oversized key locally without calling the API", async () => { + const inspect = vi.fn(); + const oversizedFile = new File([new Uint8Array(1025)], "oversized.pem", { type: "application/x-pem-file" }); + + render(); + + expect(await screen.findByText(/exceeds the 1,024 byte limit/)).toBeVisible(); + expect(inspect).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/hooks/useKeyInspection.ts b/web/src/hooks/useKeyInspection.ts new file mode 100644 index 0000000..832b4c6 --- /dev/null +++ b/web/src/hooks/useKeyInspection.ts @@ -0,0 +1,80 @@ +import { useEffect, useState } from "react"; +import { inspectKey, type InspectKeyOperation, type KeyInspectResult } from "../api"; + +type KeyInspectionState = { + sourceFile: File | null; + result: KeyInspectResult | null; + error: string | null; + loading: boolean; +}; + +type KeyInspectionValue = Omit; + +const INITIAL_STATE: KeyInspectionState = { + sourceFile: null, + result: null, + error: null, + loading: false +}; + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message ? error.message : "Unable to inspect this key file."; +} + +export function useKeyInspection( + file: File | null, + maxBytes: number, + inspect: InspectKeyOperation = inspectKey +): KeyInspectionValue { + const [state, setState] = useState(INITIAL_STATE); + + useEffect(() => { + if (!file) { + setState(INITIAL_STATE); + return; + } + + if (file.size > maxBytes) { + setState({ + sourceFile: file, + result: null, + error: `This key file exceeds the ${maxBytes.toLocaleString()} byte limit.`, + loading: false + }); + return; + } + + const controller = new AbortController(); + let current = true; + setState({ sourceFile: file, result: null, error: null, loading: true }); + + inspect(file, controller.signal) + .then((result) => { + if (!current || controller.signal.aborted) return; + setState({ sourceFile: file, result, error: null, loading: false }); + }) + .catch((error: unknown) => { + if (!current || controller.signal.aborted || (error instanceof Error && error.name === "AbortError")) return; + setState({ sourceFile: file, result: null, error: errorMessage(error), loading: false }); + }); + + return () => { + current = false; + controller.abort(); + }; + }, [file, inspect, maxBytes]); + + if (state.sourceFile !== file) { + return { + result: null, + error: null, + loading: file !== null && file.size <= maxBytes + }; + } + + return { + result: state.result, + error: state.error, + loading: state.loading + }; +} diff --git a/web/src/lib/download.ts b/web/src/lib/download.ts new file mode 100644 index 0000000..0d624f8 --- /dev/null +++ b/web/src/lib/download.ts @@ -0,0 +1,19 @@ +import type { DownloadResult } from "../api/contracts"; + +export function downloadBlob(result: DownloadResult): void { + const url = URL.createObjectURL(result.blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = result.filename; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); +} + +export function downloadText(filename: string, text: string): void { + downloadBlob({ + filename, + blob: new Blob([text], { type: "application/x-pem-file" }) + }); +} diff --git a/web/src/lib/filenames.test.ts b/web/src/lib/filenames.test.ts new file mode 100644 index 0000000..ab5306e --- /dev/null +++ b/web/src/lib/filenames.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { suggestedDecryptedName, suggestedEncryptedName } from "./filenames"; + +describe("filename suggestions", () => { + it("creates the current encrypted filename convention", () => { + expect(suggestedEncryptedName(new File(["x"], "report.pdf"))).toBe( + "report_encrypted.pqc" + ); + }); + + it("recovers the original stem from the encrypted convention", () => { + expect(suggestedDecryptedName(new File(["x"], "report_encrypted.pqc"))).toBe( + "report" + ); + }); + + it("uses a safe fallback for a bare extension", () => { + expect(suggestedDecryptedName(new File(["x"], ".pqc"))).toBe("decrypted.bin"); + }); +}); diff --git a/web/src/lib/filenames.ts b/web/src/lib/filenames.ts new file mode 100644 index 0000000..31b5f7c --- /dev/null +++ b/web/src/lib/filenames.ts @@ -0,0 +1,19 @@ +export function suggestedEncryptedName(file: File | null): string { + if (!file) return "encrypted-file.pqc"; + const dot = file.name.lastIndexOf("."); + const stem = dot > 0 ? file.name.slice(0, dot) : file.name || "file"; + return `${stem}_encrypted.pqc`; +} + +export function suggestedDecryptedName(file: File | null): string { + if (!file) return "decrypted.bin"; + const name = file.name; + if (name === ".pqc") return "decrypted.bin"; + if (name.endsWith("_encrypted.pqc")) { + return name.slice(0, -"_encrypted.pqc".length) || "decrypted.bin"; + } + if (name.endsWith(".pqc")) return `${name.slice(0, -".pqc".length)}_decrypted.bin`; + const dot = name.lastIndexOf("."); + if (dot > 0) return `${name.slice(0, dot)}_decrypted${name.slice(dot)}`; + return `${name}_decrypted.bin`; +} diff --git a/web/src/lib/format.test.ts b/web/src/lib/format.test.ts new file mode 100644 index 0000000..1d36a26 --- /dev/null +++ b/web/src/lib/format.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { formatBytes } from "./format"; + +describe("formatBytes", () => { + it("uses binary units without overstating precision", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(1024)).toBe("1.00 KiB"); + expect(formatBytes(100 * 1024 * 1024)).toBe("100 MiB"); + }); +}); diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 0000000..b0b4b79 --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,13 @@ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + if (bytes < 1024) return String(bytes) + " B"; + const units = ["KiB", "MiB", "GiB"]; + let value = bytes / 1024; + let index = 0; + while (value >= 1024 && index < units.length - 1) { + value /= 1024; + index += 1; + } + const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2; + return value.toFixed(precision) + " " + units[index]; +} diff --git a/web/src/lib/workflow.test.ts b/web/src/lib/workflow.test.ts new file mode 100644 index 0000000..aa9b469 --- /dev/null +++ b/web/src/lib/workflow.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { deriveWorkflowPhase } from "./workflow"; + +describe("deriveWorkflowPhase", () => { + it("does not report review before all inputs are ready", () => { + expect(deriveWorkflowPhase({ ready: false, complete: false })).toBe("select"); + }); + + it("reports review only for ready inputs", () => { + expect(deriveWorkflowPhase({ ready: true, complete: false })).toBe("review"); + }); + + it("prioritizes a completed result", () => { + expect(deriveWorkflowPhase({ ready: true, complete: true })).toBe("complete"); + }); +}); diff --git a/web/src/lib/workflow.ts b/web/src/lib/workflow.ts new file mode 100644 index 0000000..dad440e --- /dev/null +++ b/web/src/lib/workflow.ts @@ -0,0 +1,6 @@ +export type WorkflowPhase = "select" | "review" | "complete"; + +export function deriveWorkflowPhase(state: { ready: boolean; complete: boolean }): WorkflowPhase { + if (state.complete) return "complete"; + return state.ready ? "review" : "select"; +} diff --git a/web/src/main.tsx b/web/src/main.tsx index fa1b97e..a41d533 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,7 +1,9 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import App from "./App"; -import "./styles.css"; +import App from "./app/App"; +import "./styles/tokens.css"; +import "./styles/global.css"; +import "./styles/components.css"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/web/src/styles.css b/web/src/styles.css deleted file mode 100644 index 46eb3dd..0000000 --- a/web/src/styles.css +++ /dev/null @@ -1,564 +0,0 @@ -:root { - color-scheme: dark; - font-family: - Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - background: #000000; - color: #f9fafb; - font-synthesis: none; - text-rendering: optimizeLegibility; - --bg: #000000; - --sidebar: #0d1321; - --surface: #07111f; - --surface-2: #0b1628; - --surface-3: #0e1c31; - --border: #1d2b44; - --border-strong: #2f4364; - --text: #f9fafb; - --muted: #b6c2d1; - --subtle: #748399; - --primary: #3b82f6; - --primary-strong: #2563eb; - --accent: #22d3ee; - --success: #22c55e; - --warning: #f59e0b; - --error: #ef4444; - --radius: 8px; -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - min-width: 320px; - min-height: 100vh; - background: - linear-gradient(140deg, rgba(7, 17, 31, 0.94), rgba(0, 0, 0, 0.94) 34%), - var(--bg); -} - -button, -input { - font: inherit; -} - -button { - cursor: pointer; -} - -button:disabled { - cursor: not-allowed; -} - -.app-shell { - min-height: 100vh; - display: grid; - grid-template-columns: 280px minmax(0, 1fr); -} - -.sidebar { - background: var(--sidebar); - border-right: 1px solid var(--border); - padding: 28px 20px; - display: flex; - flex-direction: column; - gap: 22px; -} - -.brand-block { - display: grid; - grid-template-columns: 44px 1fr; - gap: 12px; - align-items: center; -} - -.brand-mark { - display: grid; - place-items: center; - height: 44px; - border: 1px solid rgba(34, 211, 238, 0.44); - border-radius: var(--radius); - background: linear-gradient(135deg, rgba(34, 211, 238, 0.16), rgba(59, 130, 246, 0.18)); - color: var(--accent); - font-size: 13px; - font-weight: 800; - letter-spacing: 0; -} - -.brand-block h1, -.workflow-header h2, -.panel h3 { - margin: 0; - letter-spacing: 0; -} - -.brand-block h1 { - font-size: 18px; - line-height: 1.15; -} - -.brand-block p, -.workflow-header p, -.empty-copy { - margin: 5px 0 0; - color: var(--muted); -} - -.brand-block p { - font-size: 12px; -} - -.nav-group { - display: grid; - gap: 8px; -} - -.nav-heading, -.eyebrow { - margin: 0; - color: var(--subtle); - font-size: 11px; - font-weight: 800; - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.nav-item { - width: 100%; - border: 1px solid transparent; - border-radius: var(--radius); - padding: 12px 12px; - background: transparent; - color: var(--muted); - text-align: left; -} - -.nav-item:hover, -.nav-item:focus-visible { - border-color: var(--border-strong); - color: var(--text); - outline: none; -} - -.nav-item.active { - border-color: rgba(59, 130, 246, 0.56); - background: rgba(59, 130, 246, 0.13); - color: var(--text); -} - -.system-card, -.notice, -.panel, -.meta-badge, -.file-drop, -.download-card { - border: 1px solid var(--border); - border-radius: var(--radius); - background: var(--surface); -} - -.system-card { - display: grid; - grid-template-columns: 10px 1fr; - gap: 12px; - padding: 14px; -} - -.status-dot { - width: 10px; - height: 10px; - border-radius: 999px; - margin-top: 4px; -} - -.status-dot.ready { - background: var(--success); - box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.12); -} - -.status-dot.warning { - background: var(--warning); - box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.12); -} - -.system-title { - margin: 0; - font-size: 13px; - font-weight: 800; -} - -.system-text { - margin: 4px 0 0; - color: var(--muted); - font-size: 12px; -} - -.metadata-grid { - display: grid; - grid-template-columns: 1fr; - gap: 8px; - margin-top: auto; -} - -.meta-badge { - padding: 10px 12px; -} - -.meta-badge span, -.field-row span, -.download-card span { - display: block; - color: var(--subtle); - font-size: 11px; - font-weight: 800; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.meta-badge strong, -.field-row strong, -.download-card strong { - display: block; - margin-top: 3px; - color: var(--text); - font-size: 13px; - word-break: break-word; -} - -.main-panel { - min-width: 0; - padding: 28px 38px 48px; -} - -.notice { - padding: 13px 14px; - color: var(--muted); - font-size: 13px; - line-height: 1.5; -} - -.notice.info { - border-color: rgba(96, 165, 250, 0.38); - background: rgba(96, 165, 250, 0.1); - color: #bfdbfe; -} - -.notice.success { - border-color: rgba(34, 197, 94, 0.38); - background: rgba(34, 197, 94, 0.1); - color: #bbf7d0; -} - -.notice.warning { - border-color: rgba(245, 158, 11, 0.44); - background: rgba(245, 158, 11, 0.11); - color: #fde68a; -} - -.notice.error { - border-color: rgba(239, 68, 68, 0.44); - background: rgba(239, 68, 68, 0.1); - color: #fecaca; -} - -.workflow { - max-width: 1120px; -} - -.workflow > .notice { - margin-bottom: 18px; -} - -.workflow-header { - display: flex; - align-items: end; - justify-content: space-between; - gap: 24px; - margin-bottom: 22px; -} - -.workflow-header h2 { - font-size: clamp(28px, 3vw, 38px); - line-height: 1.05; -} - -.workflow-header p { - max-width: 620px; - font-size: 15px; -} - -.stepper { - list-style: none; - margin: 0 0 22px; - padding: 0; - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 10px; -} - -.step { - min-width: 0; - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 10px; - color: var(--subtle); - background: rgba(11, 22, 40, 0.52); - font-size: 12px; - font-weight: 800; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.step span { - display: inline-grid; - place-items: center; - width: 22px; - height: 22px; - margin-right: 8px; - border-radius: 999px; - background: #101c2e; - color: var(--muted); -} - -.step.active { - border-color: rgba(59, 130, 246, 0.52); - color: var(--text); -} - -.step.active span { - background: var(--primary); - color: white; -} - -.panel-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(340px, 0.85fr); - gap: 18px; -} - -.panel { - padding: 20px; - background: linear-gradient(180deg, var(--surface-2), var(--surface)); -} - -.panel h3 { - margin-bottom: 16px; - font-size: 18px; -} - -.file-drop { - display: grid; - grid-template-columns: 118px 1fr; - align-items: center; - gap: 14px; - padding: 14px; - margin-bottom: 12px; - color: var(--muted); -} - -.file-drop:hover, -.file-drop:focus-within { - border-color: var(--border-strong); -} - -.file-drop.has-file { - border-color: rgba(34, 211, 238, 0.44); -} - -.file-drop input { - position: absolute; - inline-size: 1px; - block-size: 1px; - overflow: hidden; - opacity: 0; -} - -.file-action { - display: inline-grid; - place-items: center; - min-height: 44px; - border: 1px solid var(--border-strong); - border-radius: var(--radius); - background: rgba(0, 0, 0, 0.28); - color: var(--text); - font-size: 13px; - font-weight: 800; -} - -.file-copy { - min-width: 0; -} - -.file-copy strong, -.file-copy small { - display: block; -} - -.file-copy strong { - overflow: hidden; - color: var(--text); - font-size: 14px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.file-copy small { - margin-top: 4px; - color: var(--subtle); - font-size: 12px; -} - -.field-row { - display: grid; - grid-template-columns: 132px minmax(0, 1fr); - gap: 12px; - align-items: start; - padding: 12px 0; - border-bottom: 1px solid rgba(29, 43, 68, 0.7); -} - -.field-row:first-of-type { - border-top: 1px solid rgba(29, 43, 68, 0.7); -} - -.input-label { - display: grid; - gap: 8px; - margin: 16px 0; - color: var(--muted); - font-size: 12px; - font-weight: 800; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.input-label input { - width: 100%; - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 13px 14px; - background: #050b14; - color: var(--text); - outline: none; - text-transform: none; - letter-spacing: 0; -} - -.input-label input:focus { - border-color: var(--primary); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.16); -} - -.primary-button { - width: 100%; - border: 1px solid rgba(59, 130, 246, 0.9); - border-radius: var(--radius); - padding: 13px 16px; - background: linear-gradient(180deg, var(--primary), var(--primary-strong)); - color: white; - font-size: 14px; - font-weight: 900; - box-shadow: 0 12px 28px rgba(37, 99, 235, 0.22); -} - -.primary-button:hover:not(:disabled), -.primary-button:focus-visible:not(:disabled) { - filter: brightness(1.08); - outline: none; -} - -.primary-button:disabled { - border-color: var(--border); - background: #101827; - color: var(--subtle); - box-shadow: none; -} - -.checklist { - list-style: none; - margin: 0 0 16px; - padding: 0; - display: grid; - gap: 8px; -} - -.checklist li { - display: flex; - align-items: center; - gap: 10px; - color: var(--muted); - font-size: 13px; -} - -.checklist span { - width: 12px; - height: 12px; - border: 1px solid var(--border-strong); - border-radius: 999px; -} - -.checklist li.ok span { - border-color: var(--success); - background: var(--success); -} - -.download-grid { - display: grid; - gap: 12px; -} - -.download-card { - padding: 16px; - text-align: left; - color: var(--text); -} - -.download-card:hover, -.download-card:focus-visible { - border-color: var(--primary); - outline: none; -} - -.download-card.danger { - border-color: rgba(245, 158, 11, 0.44); -} - -.metadata-table { - display: grid; -} - -@media (max-width: 980px) { - .app-shell { - grid-template-columns: 1fr; - } - - .sidebar { - position: static; - } - - .metadata-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .panel-grid { - grid-template-columns: 1fr; - } - - .stepper { - grid-template-columns: 1fr; - } -} - -@media (max-width: 560px) { - .main-panel, - .sidebar { - padding: 20px; - } - - .file-drop, - .field-row { - grid-template-columns: 1fr; - } -} diff --git a/web/src/styles/components.css b/web/src/styles/components.css new file mode 100644 index 0000000..629c77b --- /dev/null +++ b/web/src/styles/components.css @@ -0,0 +1,609 @@ +@import "./tokens.css"; + +button { + min-height: 44px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius); + padding: var(--space-2) var(--space-4); + background: var(--color-action); + color: var(--color-action-text); + font-weight: 700; + transition: background-color var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); +} + +button:hover:not(:disabled) { + background: #ffffff; +} + +button:disabled { + border-color: var(--color-border); + background: var(--color-surface-raised); + color: var(--color-text-subtle); +} + +.file-picker-field, +.password-field { + display: grid; + gap: var(--space-2); +} + +.file-picker { + position: relative; + display: grid; + gap: var(--space-1); + align-content: center; + border: 1px dashed var(--color-border-strong); + border-radius: var(--radius); + padding: var(--space-4); + background: var(--color-surface); + cursor: pointer; + transition: border-color var(--transition-fast), background-color var(--transition-fast); +} + +.file-picker:hover, +.file-picker-selected { + border-color: var(--color-text-muted); + background: var(--color-surface-raised); +} + +.file-picker:focus-within { + border-color: var(--focus-ring); + outline: 2px solid var(--focus-ring); + outline-offset: 3px; + background: var(--color-surface-raised); +} + +.file-picker-label, +.password-field > label { + font-weight: 700; +} + +.file-picker-prompt, +.file-picker-selection, +.field-hint { + color: var(--color-text-muted); +} + +.file-picker-selection { + overflow-wrap: anywhere; +} + +.file-picker-input { + position: absolute; + inline-size: 1px; + block-size: 1px; + opacity: 0; +} + +.field-hint, +.field-error { + margin: 0; + font-size: 0.875rem; +} + +.field-error { + color: var(--color-error); +} + +.password-field-control { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--space-2); +} + +.password-field input { + min-width: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: var(--space-2) var(--space-3); + background: var(--color-surface); + color: var(--color-text); +} + +.notice { + display: grid; + gap: var(--space-1); + border: 1px solid var(--color-border); + border-left-width: 4px; + border-radius: var(--radius); + padding: var(--space-3) var(--space-4); + background: var(--color-surface); + color: var(--color-text-muted); +} + +.notice-title { + color: var(--color-text); +} + +.notice-success { + border-left-color: var(--color-success); +} + +.notice-warning { + border-left-color: var(--color-warning); +} + +.notice-error { + border-left-color: var(--color-error); +} + +.technical-details { + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-surface); +} + +.technical-details summary { + padding: var(--space-3) var(--space-4); + cursor: pointer; + font-weight: 700; +} + +.technical-details-content { + border-top: 1px solid var(--color-border); + padding: var(--space-4); + color: var(--color-text-muted); +} + +.workflow-layout { + display: grid; + gap: var(--space-5); + max-width: 760px; + margin: 0 auto; + padding: var(--space-6) var(--space-4); +} + +.workflow-header h1, +.workflow-header p { + margin: 0; +} + +.workflow-header p { + margin-top: var(--space-2); + color: var(--color-text-muted); +} + +.workflow-phases { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--space-2); + margin: 0; + padding: 0; + list-style: none; +} + +.workflow-phases li { + border-bottom: 2px solid var(--color-border); + padding: var(--space-2) 0; + color: var(--color-text-subtle); + font-size: 0.875rem; + font-weight: 700; +} + +.workflow-phases .is-current { + border-color: var(--color-text); + color: var(--color-text); +} + +.workflow-content { + display: grid; + gap: var(--space-4); +} + +.key-inspection-result { + display: grid; + gap: var(--space-3); +} + +.generate-keys-form, +.generated-key-downloads { + display: grid; + gap: var(--space-4); +} + +.encryption-form, +.decryption-form, +.workflow-review, +.output-filename-field, +.review-list { + display: grid; + gap: var(--space-3); +} + +.workflow-review { + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: var(--space-4); + background: var(--color-surface); +} + +.workflow-review h2 { + margin: 0; + font-size: 1rem; +} + +.review-list { + margin: 0; +} + +.review-list > div { + display: grid; + grid-template-columns: minmax(8rem, 1fr) minmax(0, 2fr); + gap: var(--space-3); +} + +.review-list dt { + color: var(--color-text-subtle); + font-weight: 700; +} + +.review-list dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +.output-filename-field label { + font-weight: 700; +} + +.output-filename-field input { + min-height: 44px; + min-width: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: var(--space-2) var(--space-3); + background: var(--color-surface); + color: var(--color-text); + font: inherit; +} + +.workflow-readiness-reason { + margin: 0; + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.password-policy { + display: grid; + gap: var(--space-2); + margin: 0; + padding: 0; + list-style: none; + color: var(--color-text-muted); +} + +.password-policy li { + display: flex; + gap: var(--space-2); + align-items: center; +} + +.password-policy li span { + inline-size: 1.25rem; + color: var(--color-text-subtle); + font-weight: 700; + text-align: center; +} + +.password-policy .password-policy-met { + color: var(--color-text); +} + +.password-policy .password-policy-met span { + color: var(--color-success); +} + +.generated-key-download-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-3); +} + +.generated-key-download-actions button { + display: grid; + gap: var(--space-1); + min-width: 0; + text-align: left; +} + +.generated-key-download-actions span { + color: var(--color-text-muted); + font-size: 0.875rem; + font-weight: 400; +} + +.generated-key-download-actions strong { + overflow-wrap: anywhere; +} + +.metadata-list { + display: grid; + gap: var(--space-2); + margin: 0; +} + +.metadata-list > div { + display: grid; + grid-template-columns: minmax(9rem, 1fr) minmax(0, 2fr); + gap: var(--space-3); +} + +.metadata-list dt { + color: var(--color-text-subtle); + font-weight: 700; +} + +.metadata-list dd { + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +@media (max-width: 520px) { + .generated-key-download-actions { + grid-template-columns: 1fr; + } + + .review-list > div { + grid-template-columns: 1fr; + gap: var(--space-1); + } +} + +.app-shell { + min-height: 100vh; + background: var(--color-canvas); +} + +.app-rail, +.app-header { + border-color: var(--color-border); + background: var(--color-rail); +} + +.app-rail { + display: none; +} + +.app-header { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(168px, 240px) auto; + gap: var(--space-3); + align-items: center; + border-bottom: 1px solid var(--color-border); + padding: var(--space-3) var(--space-4); +} + +.app-brand { + display: flex; + min-width: 0; + gap: var(--space-2); + align-items: center; +} + +.app-brand > span:last-child { + display: grid; + min-width: 0; +} + +.app-brand strong, +.app-brand small { + overflow-wrap: anywhere; +} + +.app-brand small { + color: var(--color-text-muted); +} + +.app-brand-mark { + display: grid; + flex: 0 0 44px; + place-items: center; + inline-size: 44px; + block-size: 44px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius); + color: var(--color-text); + font-size: 0.75rem; + font-weight: 800; +} + +.app-workflow-select { + display: grid; + gap: var(--space-1); + color: var(--color-text-muted); + font-size: 0.875rem; + font-weight: 700; +} + +.app-workflow-select select { + min-height: 44px; + max-width: 100%; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius); + padding: 0 var(--space-3); + background: var(--color-surface); + color: var(--color-text); + font: inherit; +} + +.app-main { + min-width: 0; + width: 100%; + max-width: 1120px; + margin: 0 auto; + padding: var(--space-6); +} + +.app-rail-navigation { + display: grid; + gap: var(--space-2); +} + +.app-rail-navigation p { + margin: 0; + color: var(--color-text-subtle); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.app-navigation-item { + width: 100%; + border-color: transparent; + background: transparent; + color: var(--color-text-muted); + text-align: left; +} + +.app-navigation-item:hover:not(:disabled), +.app-navigation-item.is-current { + border-color: var(--color-border-strong); + background: var(--color-surface-raised); + color: var(--color-text); +} + +.capability-status { + display: grid; + gap: var(--space-2); + border-top: 1px solid var(--color-border); + padding-top: var(--space-4); +} + +.capability-status-summary { + display: grid; + grid-template-columns: auto 1fr auto; + gap: var(--space-2); + align-items: center; + margin: 0; + color: var(--color-text-muted); +} + +.capability-status-summary strong { + color: var(--color-text); +} + +.capability-status-dot { + inline-size: 10px; + block-size: 10px; + border-radius: 50%; +} + +.capability-status-dot-ready { + background: var(--color-success); +} + +.capability-status-dot-limited { + background: var(--color-warning); +} + +.capability-status-dot-unavailable { + background: var(--color-error); +} + +.capability-status-details summary { + min-height: 44px; + cursor: pointer; + color: var(--color-text-muted); + font-weight: 700; +} + +.capability-status-details ul { + display: grid; + gap: var(--space-2); + margin: var(--space-2) 0 0; + padding: 0; + list-style: none; +} + +.capability-status-details li { + display: grid; + gap: var(--space-1); + padding: var(--space-2) 0; + border-top: 1px solid var(--color-border); +} + +.capability-status-details li span { + color: var(--color-text-muted); + overflow-wrap: anywhere; +} + +.capability-status-compact { + min-width: 180px; + border-top: 0; + padding-top: 0; +} + +.app-mobile-navigation { + display: none; +} + +@media (min-width: 1024px) { + .app-shell { + display: grid; + grid-template-columns: 256px minmax(0, 1fr); + } + + .app-rail { + display: flex; + flex-direction: column; + gap: var(--space-5); + min-height: 100vh; + border-right: 1px solid var(--color-border); + padding: var(--space-5) var(--space-4); + } + + .app-rail .capability-status { + margin-top: auto; + } + + .app-header, + .app-mobile-navigation { + display: none; + } +} + +@media (max-width: 767px) { + .app-header { + grid-template-columns: minmax(0, 1fr) auto; + } + + .app-workflow-select { + display: none; + } + + .capability-status-compact { + min-width: 0; + } + + .app-main { + padding: var(--space-4); + } + + .app-mobile-navigation { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border-bottom: 1px solid var(--color-border); + background: var(--color-rail); + } + + .app-mobile-navigation a { + display: grid; + min-width: 0; + min-height: 44px; + place-items: center; + padding: var(--space-2); + color: var(--color-text-muted); + font-size: 0.75rem; + font-weight: 700; + text-align: center; + text-decoration: none; + } + + .app-mobile-navigation a[aria-current="page"] { + color: var(--color-text); + box-shadow: inset 0 -2px 0 var(--color-text); + } +} diff --git a/web/src/styles/global.css b/web/src/styles/global.css new file mode 100644 index 0000000..b905341 --- /dev/null +++ b/web/src/styles/global.css @@ -0,0 +1,29 @@ +@import "./tokens.css"; + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: var(--color-canvas); + color: var(--color-text); + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; +} diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css new file mode 100644 index 0000000..7c3dd8d --- /dev/null +++ b/web/src/styles/tokens.css @@ -0,0 +1,50 @@ +:root { + color-scheme: dark; + --color-canvas: #090909; + --color-rail: #0d0d0d; + --color-surface: #141414; + --color-surface-raised: #1b1b1b; + --color-border: #343434; + --color-border-strong: #555555; + --color-text: #f2f2ee; + --color-text-muted: #aaa9a3; + --color-text-subtle: #8a8a85; + --color-action: #f2f2ee; + --color-action-text: #0b0b0b; + --color-success: #78b86b; + --color-warning: #d3a24c; + --color-error: #d66a63; + --focus-ring: #f2f2ee; + --radius: 10px; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 24px; + --space-6: 32px; + --space-7: 48px; + --transition-fast: 140ms ease; +} + +:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 3px; +} + +button, +input, +summary, +.file-picker { + min-height: 44px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts new file mode 100644 index 0000000..6d57f2d --- /dev/null +++ b/web/src/test/fixtures.ts @@ -0,0 +1,26 @@ +import type { Health } from "../api/contracts"; + +export const READY_HEALTH: Health = { + ok: true, + backendReady: true, + backendMessage: "Post-quantum backend ready.", + capabilities: { + inspect: { available: true, reason: "" }, + generate: { available: true, reason: "" }, + encrypt: { available: true, reason: "" }, + decrypt: { available: true, reason: "" } + }, + formatVersion: 4, + kem: "ML-KEM-768+X25519-v2", + kemComponent: "ML-KEM-768", + configuredKem: "ML-KEM-768", + dem: "AES-256-GCM", + maxFileBytes: 104857600, + maxEncryptedFileBytes: 105906314, + maxPemBytes: 131072, + apiToken: "test-local-token", + passwordPolicy: { + minChars: 16, + minUniqueChars: 5 + } +}; diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts new file mode 100644 index 0000000..e262193 --- /dev/null +++ b/web/src/test/setup.ts @@ -0,0 +1,7 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => { + cleanup(); +});