diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9666a68..565c046 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,9 +44,9 @@ jobs: - name: Install lock compiler run: | - python -m pip install --no-deps "pip==25.3" + python -m pip install --no-deps "pip==26.2.1" python -m pip install --no-deps \ - "pip-tools==7.5.3" \ + "pip-tools==7.6.1" \ "packaging==26.2" \ "build==1.5.0" \ "click==8.4.2" \ @@ -83,13 +83,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 ui_helpers.py scripts/check_dependency_locks.py scripts/check_release_preflight.py setup.py tests/test_agent_tools.py tests/test_api_app.py tests/test_crypto_core.py tests/test_dependency_locks.py tests/test_release_preflight.py tests/test_ui_helpers.py + run: python -m black --check api_app.py api_worker.py crypto_config.py crypto_core.py pqc_agent_tools.py ui_helpers.py scripts/check_dependency_locks.py scripts/check_release_preflight.py setup.py tests/test_agent_tools.py tests/test_api_app.py tests/test_api_worker.py tests/test_crypto_core.py tests/test_dependency_locks.py tests/test_release_preflight.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 ui_helpers.py scripts/check_dependency_locks.py scripts/check_release_preflight.py setup.py tests/test_agent_tools.py tests/test_api_app.py tests/test_crypto_core.py tests/test_dependency_locks.py tests/test_release_preflight.py tests/test_ui_helpers.py + run: python -m flake8 api_app.py api_worker.py crypto_config.py crypto_core.py pqc_agent_tools.py ui_helpers.py scripts/check_dependency_locks.py scripts/check_release_preflight.py setup.py tests/test_agent_tools.py tests/test_api_app.py tests/test_api_worker.py tests/test_crypto_core.py tests/test_dependency_locks.py tests/test_release_preflight.py tests/test_ui_helpers.py - name: Type check - run: python -m mypy --explicit-package-bases api_app.py crypto_config.py crypto_core.py pqc_agent_tools.py ui_helpers.py scripts/check_dependency_locks.py scripts/check_release_preflight.py tests/test_agent_tools.py tests/test_api_app.py tests/test_crypto_core.py tests/test_dependency_locks.py tests/test_release_preflight.py tests/test_ui_helpers.py + run: python -m mypy --explicit-package-bases api_app.py api_worker.py crypto_config.py crypto_core.py pqc_agent_tools.py ui_helpers.py scripts/check_dependency_locks.py scripts/check_release_preflight.py tests/test_agent_tools.py tests/test_api_app.py tests/test_api_worker.py tests/test_crypto_core.py tests/test_dependency_locks.py tests/test_release_preflight.py tests/test_ui_helpers.py - name: Unit tests without native liboqs run: > @@ -98,6 +98,7 @@ jobs: --cov=pqc_agent_tools --cov=ui_helpers --cov=api_app + --cov=api_worker --cov-report=term-missing --cov-fail-under=80 @@ -150,11 +151,12 @@ jobs: import sys import api_app + import api_worker import pqc_agent_tools import ui_helpers prefix = Path(sys.prefix).resolve() - for module in (api_app, pqc_agent_tools, ui_helpers): + for module in (api_app, api_worker, 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 @@ -214,7 +216,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 ui_helpers.py + run: python -m bandit -q -r api_app.py api_worker.py crypto_core.py pqc_agent_tools.py ui_helpers.py web: name: Custom web UI @@ -291,6 +293,7 @@ jobs: import crypto_config import crypto_core import api_app + import api_worker import pqc_agent_tools import ui_helpers diff --git a/.gitignore b/.gitignore index b65f229..55e1d41 100644 --- a/.gitignore +++ b/.gitignore @@ -133,6 +133,7 @@ dmypy.json *.swo # Project specific +.oqs/ *.pem *.pqc test_files/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a22f7ed..2479e5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This project follows a practical semantic-versioning style. ### Added +- Responsive API cryptographic processing with one admitted generate/encrypt/decrypt request per server process, separate health work, and HTTP `429` busy responses with `Retry-After: 1`. Cancellation keeps the slot occupied until native work and the request/response lifecycle finish; sensitive requests are retried manually. - 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, @@ -26,6 +27,7 @@ This project follows a practical semantic-versioning style. ### Security +- Updated development and CI lock tooling to pip 26.2.1 and compatible pip-tools 7.6.1 to address GHSA-qwm4-qh6w-59xr, with matching dependency floors and generated hashes. - Updated the locked Nano ID and PostCSS transitive dependencies to releases that address their current security advisories. - Raised the minimum `cryptography` version to 50.0.0 and refreshed the hash-locked runtime and development dependency sets to exclude the vulnerable 49.0.0 release. - New encryption requires composite public keys and cannot silently downgrade to the legacy single-KEM format. diff --git a/README.md b/README.md index e93b0be..3f79cad 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,8 @@ See [docs/SCREENSHOTS.md](docs/SCREENSHOTS.md) for the dedicated screenshot page Each workflow starts with plain-language guidance. Expand **Technical details** only when you need suite, format, or key-policy information. +Key generation, encryption, and decryption share one processing slot per local server process. Cryptographic work runs outside the API event loop so other requests can proceed. If another tab is already using the slot, the service returns a busy response; wait for that operation to finish and retry manually. Canceling a browser request does not stop native work already running. See the [concurrent request contract](docs/API.md#concurrent-requests) for client behavior and limits. + ### Local-only interface privacy The custom interface processes selected files through the local Python service at `127.0.0.1`. It does not write generated keys to persistent web storage, collect telemetry, or load remote fonts or remote application assets. The UI does not display plaintext previews, passwords, private-key content, or the local API token. diff --git a/api_app.py b/api_app.py index 134dac0..b4abed8 100644 --- a/api_app.py +++ b/api_app.py @@ -8,12 +8,14 @@ import re import secrets import sys +from contextlib import asynccontextmanager from http.cookies import SimpleCookie from pathlib import Path -from typing import Any +from typing import Any, AsyncIterator from urllib.parse import quote, urlsplit from starlette.applications import Starlette +from starlette.concurrency import run_in_threadpool from starlette.datastructures import UploadFile from starlette.requests import Request from starlette.responses import JSONResponse, PlainTextResponse, Response @@ -22,6 +24,7 @@ from starlette.types import ASGIApp, Message, Receive, Scope, Send from crypto_config import cfg +from api_worker import CryptoWorker import crypto_core as core from ui_helpers import format_key_info_for_display, guess_decrypted_filename @@ -447,6 +450,39 @@ async def limited_receive() -> Message: ) +class CryptoAdmissionMiddleware: + """Bound uploaded data and expensive work before reading an admitted request.""" + + paths = frozenset({"/api/keys/generate", "/api/files/encrypt", "/api/files/decrypt"}) + + def __init__(self, app: ASGIApp, worker: CryptoWorker) -> None: + self.app = app + self.worker = worker + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or scope.get("method") != "POST" or scope.get("path") not in self.paths: + await self.app(scope, receive, send) + return + lease = self.worker.acquire() + if lease is None: + response = _json_error( + ApiError( + 429, + "server_busy", + "The local service is processing another operation. Wait for it to finish, then try again.", + ) + ) + response.headers["Retry-After"] = "1" + await response(scope, receive, send) + return + scope.setdefault("state", {})["crypto_lease"] = lease + try: + await self.app(scope, receive, send) + finally: + # The lease also waits for any worker left running after cancellation. + lease.close() + + def _form_text(form: Any, name: str, required: bool = True) -> str: value = form.get(name) if value is None: @@ -555,7 +591,7 @@ async def health(request: Request) -> JSONResponse: _has_valid_origin, authority_error = _validate_request_authorities(request.scope) if authority_error is not None: return _json_error(authority_error) - response = _success_json(_health_payload()) + response = _success_json(await run_in_threadpool(_health_payload)) # Deliver the per-process API token only as an HttpOnly, SameSite=Strict cookie so it # is never exposed in response bodies or to JavaScript, and is not sent cross-site. response.set_cookie( @@ -588,38 +624,41 @@ async def inspect_key(request: Request) -> JSONResponse: return _safe_unexpected("inspect-key", exc) +def _generate_key_pair(password: str) -> dict[str, Any]: + try: + core.validate_private_key_password(password) + except (core.PasswordRequiredError, core.WeakPasswordError) as exc: + raise ApiError(400, "weak_password", str(exc)) from exc + + active_kem_alg = core.resolve_kem_algorithm(cfg.KEM_ALG) + raw_public_key, raw_private_key = core.generate_hybrid_keys(active_kem_alg) + if not raw_public_key or not raw_private_key: + raise ApiError(503, "backend_unavailable", "Could not generate a hybrid key pair.") + + public_key_fingerprint = core.get_public_key_fingerprint(raw_public_key, cfg.HYBRID_KEM_ALG) + public_pem = core.save_key_pem(raw_public_key, cfg.HYBRID_KEM_ALG, "public") + private_pem = core.save_key_pem(raw_private_key, cfg.HYBRID_KEM_ALG, "private", password=password) + del raw_public_key + del raw_private_key + if not public_pem or not private_pem: + raise ApiError(500, "pem_format_failed", "Could not format generated keys.") + + return { + "kem": cfg.HYBRID_KEM_ALG, + "publicPem": public_pem, + "privatePem": private_pem, + "publicKeyFingerprint": public_key_fingerprint, + "publicFilename": "ml-kem-768_x25519_public.pem", + "privateFilename": "ml-kem-768_x25519_private.pem", + } + + async def generate_keys(request: Request) -> JSONResponse: try: form = await _form(request, max_files=0) password = _form_text(form, "password") - try: - core.validate_private_key_password(password) - except (core.PasswordRequiredError, core.WeakPasswordError) as exc: - raise ApiError(400, "weak_password", str(exc)) from exc - - active_kem_alg = core.resolve_kem_algorithm(cfg.KEM_ALG) - raw_public_key, raw_private_key = core.generate_hybrid_keys(active_kem_alg) - if not raw_public_key or not raw_private_key: - raise ApiError(503, "backend_unavailable", "Could not generate a hybrid key pair.") - - public_key_fingerprint = core.get_public_key_fingerprint(raw_public_key, cfg.HYBRID_KEM_ALG) - public_pem = core.save_key_pem(raw_public_key, cfg.HYBRID_KEM_ALG, "public") - private_pem = core.save_key_pem(raw_private_key, cfg.HYBRID_KEM_ALG, "private", password=password) - del raw_public_key - del raw_private_key - if not public_pem or not private_pem: - raise ApiError(500, "pem_format_failed", "Could not format generated keys.") - - return _success_json( - { - "kem": cfg.HYBRID_KEM_ALG, - "publicPem": public_pem, - "privatePem": private_pem, - "publicKeyFingerprint": public_key_fingerprint, - "publicFilename": "ml-kem-768_x25519_public.pem", - "privateFilename": "ml-kem-768_x25519_private.pem", - } - ) + payload = await request.state.crypto_lease.run(_generate_key_pair, password) + return _success_json(payload) except ApiError as exc: return _json_error(exc) except core.CryptoDependencyError: @@ -628,6 +667,26 @@ async def generate_keys(request: Request) -> JSONResponse: return _safe_unexpected("generate-keys", exc) +def _encrypt_bytes(input_data: bytes, public_pem: str) -> bytes: + public_key_bytes, kem_alg_from_key, key_type = core.load_key_pem(public_pem) + if not public_key_bytes or not kem_alg_from_key or key_type != "public": + raise ApiError(400, "invalid_public_key", "Upload a supported PQC public key PEM file.") + if kem_alg_from_key != cfg.HYBRID_KEM_ALG: + raise ApiError( + 400, + "legacy_public_key", + "Generate a new ML-KEM-768+X25519-v2 public key for encryption.", + ) + + encrypted_blob = core.encrypt_file_pro(input_data, public_key_bytes, kem_alg_from_key) + del input_data + del public_key_bytes + if encrypted_blob is None: + raise ApiError(503, "encryption_failed", "Encryption failed. Check backend readiness and key compatibility.") + + return encrypted_blob + + async def encrypt_file(request: Request) -> Response: try: form = await _form(request, max_files=2) @@ -641,23 +700,8 @@ async def encrypt_file(request: Request) -> Response: input_data = await _read_upload_bytes(uploaded_file, cfg.MAX_FILE_BYTES, "Input file") public_pem = await _read_upload_text(public_key_file, cfg.MAX_PEM_BYTES, "Public key file") - public_key_bytes, kem_alg_from_key, key_type = core.load_key_pem(public_pem) - if not public_key_bytes or not kem_alg_from_key or key_type != "public": - raise ApiError(400, "invalid_public_key", "Upload a supported PQC public key PEM file.") - if kem_alg_from_key != cfg.HYBRID_KEM_ALG: - raise ApiError( - 400, - "legacy_public_key", - "Generate a new ML-KEM-768+X25519-v2 public key for encryption.", - ) - - encrypted_blob = core.encrypt_file_pro(input_data, public_key_bytes, kem_alg_from_key) + encrypted_blob = await request.state.crypto_lease.run(_encrypt_bytes, input_data, public_pem) del input_data - del public_key_bytes - if encrypted_blob is None: - raise ApiError( - 503, "encryption_failed", "Encryption failed. Check backend readiness and key compatibility." - ) return _download_response(encrypted_blob, output_filename) except ApiError as exc: @@ -668,6 +712,33 @@ async def encrypt_file(request: Request) -> Response: return _safe_unexpected("encrypt-file", exc) +def _decrypt_bytes(encrypted_blob: bytes, private_pem: str, password: str) -> bytes: + key_info = core.inspect_key_pem_strict(private_pem) + if key_info.get("key_type") != "private": + raise ApiError(400, "invalid_private_key", "Upload a supported encrypted PQC private key PEM file.") + + private_key_bytes, kem_alg_key, key_type = core.load_key_pem(private_pem, password=password) + if not private_key_bytes or not kem_alg_key or key_type != "private": + raise ApiError(400, "private_key_failed", "Could not unlock the private key. Check the password and key file.") + + core.resolve_decryption_kem_algorithms(kem_alg_key) + decrypted_data, _detected_alg = core.decrypt_file_pro( + encrypted_blob, + private_key_bytes, + expected_kem_alg=kem_alg_key, + ) + del encrypted_blob + del private_key_bytes + if decrypted_data is None: + raise ApiError( + 400, + "decryption_failed", + "Decryption failed. Check the private key, password, and encrypted file integrity.", + ) + + return decrypted_data + + async def decrypt_file(request: Request) -> Response: try: form = await _form(request, max_files=2) @@ -682,30 +753,8 @@ async def decrypt_file(request: Request) -> Response: encrypted_blob = await _read_upload_bytes(encrypted_upload, cfg.MAX_ENCRYPTED_FILE_BYTES, "Encrypted file") private_pem = await _read_upload_text(private_key_file, cfg.MAX_PEM_BYTES, "Private key file") - key_info = core.inspect_key_pem_strict(private_pem) - if key_info.get("key_type") != "private": - raise ApiError(400, "invalid_private_key", "Upload a supported encrypted PQC private key PEM file.") - - private_key_bytes, kem_alg_key, key_type = core.load_key_pem(private_pem, password=password) - if not private_key_bytes or not kem_alg_key or key_type != "private": - raise ApiError( - 400, "private_key_failed", "Could not unlock the private key. Check the password and key file." - ) - - core.resolve_decryption_kem_algorithms(kem_alg_key) - decrypted_data, _detected_alg = core.decrypt_file_pro( - encrypted_blob, - private_key_bytes, - expected_kem_alg=kem_alg_key, - ) + decrypted_data = await request.state.crypto_lease.run(_decrypt_bytes, encrypted_blob, private_pem, password) del encrypted_blob - del private_key_bytes - if decrypted_data is None: - raise ApiError( - 400, - "decryption_failed", - "Decryption failed. Check the private key, password, and encrypted file integrity.", - ) media_type, _ = mimetypes.guess_type(output_filename) return _download_response(decrypted_data, output_filename, media_type or "application/octet-stream") @@ -729,6 +778,15 @@ async def frontend_missing(_request: Request) -> PlainTextResponse: def create_app() -> ASGIApp: + worker = CryptoWorker() + + @asynccontextmanager + async def lifespan(_app: Starlette) -> AsyncIterator[None]: + try: + yield + finally: + await run_in_threadpool(worker.close) + routes: list[BaseRoute] = [ Route("/api/health", health, methods=["GET"]), Route("/api/keys/inspect", inspect_key, methods=["POST"]), @@ -740,7 +798,8 @@ def create_app() -> ASGIApp: routes.append(Mount("/", StaticFiles(directory=STATIC_APP_DIR, html=True), name="web")) else: routes.append(Route("/{path:path}", frontend_missing, methods=["GET"])) - inner_app = Starlette(debug=False, routes=routes) + inner_app = Starlette(debug=False, routes=routes, lifespan=lifespan) + inner_app.add_middleware(CryptoAdmissionMiddleware, worker=worker) inner_app.add_middleware(ApiBodyLimitMiddleware) inner_app.add_middleware(LocalApiGuardMiddleware) return SecurityHeadersMiddleware(inner_app) diff --git a/api_worker.py b/api_worker.py new file mode 100644 index 0000000..cf06911 --- /dev/null +++ b/api_worker.py @@ -0,0 +1,78 @@ +"""Bounded background execution for one admitted cryptographic request.""" + +from __future__ import annotations + +import asyncio +from concurrent.futures import Future, ThreadPoolExecutor +from threading import Lock +from typing import Any, Callable, TypeVar + +Result = TypeVar("Result") + + +class CryptoWorker: + """Own one worker thread and admit one request until its work and response finish.""" + + def __init__(self) -> None: + self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="crypto-worker") + self._lock = Lock() + self._closed = False + self._lease: CryptoLease | None = None + + def acquire(self) -> CryptoLease | None: + """Admit immediately, returning None while busy or shutting down.""" + with self._lock: + if self._closed or self._lease is not None: + return None + self._lease = CryptoLease(self) + return self._lease + + def _release_if_finished(self, lease: CryptoLease) -> None: + # Call only while holding _lock; callbacks can run on the worker thread. + if self._lease is lease and lease._closed and lease._pending == 0: + self._lease = None + + def close(self) -> None: + """Stop admission, cancel queued work, and wait for executing work to finish.""" + with self._lock: + self._closed = True + self._executor.shutdown(wait=True, cancel_futures=True) + + +class CryptoLease: + """Keep admission until the request closes and all submitted functions finish.""" + + def __init__(self, worker: CryptoWorker) -> None: + self._worker = worker + self._closed = False + self._pending = 0 + + async def run(self, function: Callable[..., Result], *args: Any) -> Result: + """Run a synchronous function without blocking the request's event loop.""" + worker = self._worker + with worker._lock: + if self._closed or worker._closed: + raise RuntimeError("The cryptographic worker or request has closed.") + self._pending += 1 + try: + future = worker._executor.submit(function, *args) + except BaseException: + self._pending -= 1 + worker._release_if_finished(self) + raise + + # A cancelled asyncio wrapper may leave its thread running. Only the + # underlying concurrent future can mark that work as finished. + future.add_done_callback(self._work_finished) + return await asyncio.wrap_future(future) + + def _work_finished(self, _future: Future[Any]) -> None: + with self._worker._lock: + self._pending -= 1 + self._worker._release_if_finished(self) + + def close(self) -> None: + """Mark request/response cleanup complete without abandoning running work.""" + with self._worker._lock: + self._closed = True + self._worker._release_if_finished(self) diff --git a/docs/API.md b/docs/API.md index d6b6451..71d5bd8 100644 --- a/docs/API.md +++ b/docs/API.md @@ -58,6 +58,16 @@ The custom web UI is served by `api_app.py` at exactly `http://127.0.0.1:` `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. +### Concurrent requests + +`POST /api/keys/generate`, `POST /api/files/encrypt`, and `POST /api/files/decrypt` share one processing slot per server process. Requests are admitted after authorization and `Content-Length` checks, before body parsing. Cryptographic work runs in a dedicated pool with one worker, outside the API event loop. Health checks run separately; health, static assets, and key inspection do not require this processing slot. + +When the slot is occupied, another expensive request receives HTTP `429` with `error_code: "server_busy"` and `Retry-After: 1`. Its body is not parsed or queued for later processing. Wait at least one second and retry manually after the current operation finishes; the header does not guarantee the slot will be free then. The web client does not automatically resubmit passwords, files, or key-generation requests. + +The slot remains occupied until both the request/response lifecycle and its cryptographic worker have finished. Canceling or closing the browser request does not interrupt native work already running or permit a second operation to overlap it. This is a concurrency limit per process; additional server processes have separate limits. It does not provide password-attempt rate limiting or memory zeroization. + +Successful response bodies, algorithm selection, and encrypted-file and PEM formats are unchanged. + ### Response caching and generated-key custody Every HTTP response under `/api/*` carries `Cache-Control: no-store` and `Pragma: no-cache`, including JSON successes and errors, authorization or body-limit middleware rejections, unmatched API routes, framework-generated 500 responses, and file downloads. The policy is applied centrally so new API handlers inherit it; static UI responses outside `/api/*` keep their own cache behavior. These directives reduce retention by conforming HTTP caches but do not securely erase browser or process memory. diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 873cd47..d534c10 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -45,7 +45,7 @@ For reproducible development installs: pip install --require-hashes -r requirements-dev-lock.txt ``` -Regenerate the lock files only after intentionally changing dependency inputs. Lock generation is canonical on Ubuntu 24.04 x86-64 with Python 3.13.15, pip 25.3, pip-tools 7.5.3, packaging 26.2, build 1.5.0, click 8.4.2, pyproject-hooks 1.2.0, setuptools 83.0.0, wheel 0.47.0, Node 22.23.1, and npm 10.9.8. Use an isolated environment with those exact versions, then run: +Regenerate the lock files only after intentionally changing dependency inputs. Lock generation is canonical on Ubuntu 24.04 x86-64 with Python 3.13.15, pip 26.2.1, pip-tools 7.6.1, packaging 26.2, build 1.5.0, click 8.4.2, pyproject-hooks 1.2.0, setuptools 83.0.0, wheel 0.47.0, Node 22.23.1, and npm 10.9.8. Use an isolated environment with those exact versions, then run: ```bash CUSTOM_COMPILE_COMMAND='pip-compile --generate-hashes --output-file=requirements-lock.txt requirements.txt' \ diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 2730cfe..60f1aee 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -36,6 +36,7 @@ Quantum Encryptor protects local files with post-quantum key encapsulation and a - Supplying weak, missing, or reused private-key passwords. - Tampering with encrypted-file headers, KEM ciphertext, nonce, AES ciphertext, or authentication tag. - Feeding oversized PEM, plaintext, or encrypted-container inputs to exhaust process memory. +- Overlapping expensive API requests to exhaust CPU or memory, block the event loop, or bypass the concurrency limit by canceling requests while native work continues. - Using absolute paths, parent traversal, or symlinks to make the agent CLI read or write outside the workspace. - Triggering native backend failures during import, key generation, encryption, or decryption. - Leaking plaintext, private keys, passwords, raw bytes, or absolute local paths through JSON output or logs. @@ -56,6 +57,7 @@ Quantum Encryptor protects local files with post-quantum key encapsulation and a - Encrypted files must be authenticated format version 4 or decrypt-only version 3 and must authenticate the complete header as AES-GCM associated data. - Version 4 AES keys must bind both key shares, both X25519 public values, the suite identifier, and the application domain separator. - PEM, plaintext, and encrypted-container inputs must be bounded before expensive parsing or cryptographic work. +- Each server process admits at most one key-generation, encryption, or decryption request before body parsing and retains its slot until both the request/response lifecycle and native work finish. - Decryption failures do not produce plaintext output files. - Agent CLI paths stay workspace-relative and cannot escape through symlinks. - Agent CLI JSON output never includes secret material or absolute local paths. @@ -77,6 +79,7 @@ Quantum Encryptor protects local files with post-quantum key encapsulation and a - Application-specific SHA3-256 combiner inspired by RFC 9980's component binding, with domain separation and full-header AES-GCM authentication. - AES-256-GCM with full encrypted-file header as associated data. - Lazy native `liboqs` loading with dependency failures reported as unavailable backend state. +- Expensive API cryptography runs outside the event loop in a dedicated pool with one worker. Admission follows authorization and `Content-Length` checks; busy requests receive HTTP `429`, `server_busy`, and `Retry-After: 1` before body parsing. Health work runs separately, and health, static assets, and key inspection do not occupy this slot. Clients retry manually. - Workspace-only agent CLI path validation, exclusive non-overwrite creation, atomic replacement on explicit overwrite, and JSON-only responses. - The local web API trusts only the exact `http://127.0.0.1:` authority, plus `http://127.0.0.1:4001` only when `QUANTUM_ENCRYPTOR_ENABLE_VITE_DEV=1` enables the Vite development proxy. Cookie-authenticated state-changing requests require an allowed parsed `Origin` exactly equal to the direct allowed `Host`; Origin-less clients must use the explicit token header, and an invalid supplied header cannot fall back to the cookie. `GET /api/health` issues its `HttpOnly` cookie only for an allowed direct Host and a matching Origin when present, without trusting forwarding headers. `SameSite=Strict` helps with cross-site cookie delivery but does not isolate loopback ports; exact authority validation provides that boundary, while page JavaScript still cannot read the cookie. - All local web responses include a restrictive Content Security Policy with `frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and `Referrer-Policy: no-referrer`, blocking clickjacking of the local UI. @@ -89,7 +92,8 @@ Quantum Encryptor protects local files with post-quantum key encapsulation and a - The app processes files in memory and is not suitable for very large streaming workflows. - Python cannot guarantee secure zeroization of immutable secret byte strings. - The loopback API trusts the local machine: malicious local software can use the allowed authority to obtain and send the auth cookie, so this protection does not defend against a malicious local process. Keep the server bound to `127.0.0.1`; never expose it on a network interface. -- The local web API does not rate-limit private-key password attempts. Each attempt costs a full scrypt derivation, and an attacker holding the encrypted PEM would brute force offline instead, so online throttling adds little. +- The API concurrency limit applies separately to each server process. It does not rate-limit password attempts or protect against offline password guessing by someone holding an encrypted PEM. +- Canceling a browser request does not stop native work already running; its processing slot and required in-memory inputs remain until that work finishes. This does not provide memory zeroization. - No independent cryptographic audit or formal verification has been performed. - The application-specific file and key formats are not interoperable with OpenPGP or another standardized container format. - The hybrid construction has not undergone an independent cryptographic audit. diff --git a/pyproject.toml b/pyproject.toml index d1a4cc2..88fd91d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,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", "ui_helpers"] +py-modules = ["api_app", "api_worker", "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 1de2bb4..988e00a 100644 --- a/requirements-dev-lock.txt +++ b/requirements-dev-lock.txt @@ -972,9 +972,9 @@ pip-requirements-parser==32.0.1 \ --hash=sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526 \ --hash=sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3 # via pip-audit -pip-tools==7.5.3 \ - --hash=sha256:3aac0c473240ae90db7213c033401f345b05197293ccbdd2704e52e7a783785e \ - --hash=sha256:8fa364779ebc010cbfe17cb9de404457ac733e100840423f28f6955de7742d41 +pip-tools==7.6.1 \ + --hash=sha256:6111c8b4b07fd14b7223ca921485b0e96cf66e20bf94da95eeed9845f510cb8f \ + --hash=sha256:695556edeb647eb94ee8345cc7108657fdb7fb16b3876623a399b4f61bbede01 # via -r requirements-dev.txt platformdirs==4.10.0 \ --hash=sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7 \ @@ -1333,10 +1333,11 @@ wheel==0.47.0 \ # pip-tools # The following packages are considered to be unsafe in a requirements file: -pip==26.1.2 \ - --hash=sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab \ - --hash=sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605 +pip==26.2.1 \ + --hash=sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e \ + --hash=sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f # via + # -r requirements-dev.txt # pip-api # pip-tools setuptools==83.0.0 \ diff --git a/requirements-dev.txt b/requirements-dev.txt index e902cbd..f8dc765 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,7 +6,9 @@ mypy>=2.3.0 flake8>=6.0.0 bandit>=1.8.0 pip-audit>=2.8.0 -pip-tools>=7.5.0 +# Require the patched installer and a compatible lock compiler. +pip>=26.2.1 +pip-tools>=7.6.1 build>=1.5.0 twine>=5.0.0 # Keep the hashed dev lock portable when generated outside Linux. diff --git a/scripts/api_client.test.mjs b/scripts/api_client.test.mjs index f971c98..07329e7 100644 --- a/scripts/api_client.test.mjs +++ b/scripts/api_client.test.mjs @@ -150,6 +150,42 @@ test("an unrelated authorization rejection is not retried", async (t) => { ); }); +test("a busy response preserves server guidance without retrying the POST", async (t) => { + const originalFetch = globalThis.fetch; + t.after(() => { + globalThis.fetch = originalFetch; + }); + + const message = "The local service is processing another operation. Wait for it to finish, then try again."; + const calls = []; + globalThis.fetch = async (input, init = {}) => { + const url = String(input); + calls.push({ url, method: init.method ?? "GET" }); + if (url === "/api/health") return jsonResponse(healthPayload()); + if (url === "/api/keys/generate") { + const response = jsonResponse({ ok: false, error_code: "server_busy", message }, 429); + response.headers.set("Retry-After", "1"); + return response; + } + throw new Error(`Unexpected request: ${url}`); + }; + + const api = await loadApiModule(); + + await assert.rejects( + api.generateKeys("correct horse battery staple"), + (error) => + error instanceof api.ApiError && + error.status === 429 && + error.code === "server_busy" && + error.message === message + ); + assert.deepEqual(calls, [ + { url: "/api/health", method: "GET" }, + { url: "/api/keys/generate", method: "POST" } + ]); +}); + test("sensitive operation signals reach each state-changing fetch", async (t) => { const originalFetch = globalThis.fetch; t.after(() => { diff --git a/scripts/check_dependency_locks.py b/scripts/check_dependency_locks.py index 38a75a4..110a8b7 100644 --- a/scripts/check_dependency_locks.py +++ b/scripts/check_dependency_locks.py @@ -29,8 +29,8 @@ "distribution": "ubuntu", "distribution-version": "24.04", "python": "3.13.15", - "pip": "25.3", - "pip-tools": "7.5.3", + "pip": "26.2.1", + "pip-tools": "7.6.1", "packaging": "26.2", "build": "1.5.0", "click": "8.4.2", diff --git a/tests/test_api_app.py b/tests/test_api_app.py index 72670fc..6b7b931 100644 --- a/tests/test_api_app.py +++ b/tests/test_api_app.py @@ -1,11 +1,15 @@ import asyncio import io import json +import threading +from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path -from typing import Any +from typing import Any, AsyncIterator, Awaitable, Callable, cast from urllib.parse import urlencode import pytest +from starlette.applications import Starlette +from starlette.types import ASGIApp from crypto_config import cfg import crypto_core as core @@ -47,6 +51,198 @@ def test_health_payload_reports_ready_backend(monkeypatch): assert payload["kemComponent"] == "ML-KEM-768" +@pytest.mark.parametrize("operation", ["generate", "encrypt", "decrypt"]) +def test_crypto_operations_run_outside_event_loop(monkeypatch, operation): + event_loop_thread = threading.get_ident() + worker_threads = [] + + def record_thread(result): + worker_threads.append(threading.get_ident()) + return result + + monkeypatch.setattr(core, "resolve_kem_algorithm", lambda _kem: cfg.KEM_ALG) + monkeypatch.setattr(core, "get_public_key_fingerprint", lambda *_args: "fingerprint") + if operation == "generate": + path = "/api/keys/generate" + body, headers = _urlencoded_body({"password": "correct horse battery staple"}) + monkeypatch.setattr(core, "generate_hybrid_keys", lambda _kem: record_thread((b"public", b"private"))) + monkeypatch.setattr(core, "save_key_pem", lambda *_args, **_kwargs: "pem") + elif operation == "encrypt": + path = "/api/files/encrypt" + body, headers = _file_workflow_body() + monkeypatch.setattr(core, "load_key_pem", lambda _pem: (b"public", cfg.HYBRID_KEM_ALG, "public")) + monkeypatch.setattr(core, "encrypt_file_pro", lambda *_args: record_thread(b"encrypted")) + else: + path = "/api/files/decrypt" + body, headers = _decrypt_workflow_body() + monkeypatch.setattr(core, "inspect_key_pem_strict", lambda _pem: {"key_type": "private"}) + monkeypatch.setattr(core, "load_key_pem", lambda *_args, **_kwargs: (b"private", cfg.HYBRID_KEM_ALG, "private")) + monkeypatch.setattr(core, "resolve_decryption_kem_algorithms", lambda _kem: (cfg.KEM_ALG,)) + monkeypatch.setattr(core, "decrypt_file_pro", lambda *_args, **_kwargs: record_thread((b"plain", cfg.KEM_ALG))) + + status, _, _ = asyncio.run(_call_app_raw(path, body=body, headers=_with_api_token(headers))) + + assert status == 200 + assert worker_threads and all(thread != event_loop_thread for thread in worker_threads) + + +@pytest.mark.parametrize("busy_path", ["/api/keys/generate", "/api/files/encrypt", "/api/files/decrypt"]) +def test_busy_crypto_request_is_rejected_before_reading_uploads(busy_path): + async def exercise(): + async with _running_app() as application: + started = asyncio.Event() + release = asyncio.Event() + + async def hold_first_upload(): + started.set() + await release.wait() + + async def unexpected_read(): + pytest.fail("A rejected busy request must not read or parse its body") + + body, headers = _urlencoded_body({"password": "short"}) + first = asyncio.create_task( + _call_app_raw( + "/api/keys/generate", + body=body, + headers=_with_api_token(headers), + application=application, + before_receive=hold_first_upload, + ) + ) + try: + await asyncio.wait_for(started.wait(), 2) + unauthorized_status, _, _ = await _call_app_raw( + busy_path, + body=body, + headers=headers, + application=application, + before_receive=unexpected_read, + ) + assert unauthorized_status == 403 + oversized_headers = [ + (name, b"9999999999" if name == b"content-length" else value) for name, value in headers + ] + oversized_status, _, _ = await _call_app_raw( + busy_path, + body=body, + headers=_with_api_token(oversized_headers), + application=application, + before_receive=unexpected_read, + ) + assert oversized_status == 413 + status, response_headers, response_body = await _call_app_raw( + busy_path, + body=body, + headers=_with_api_token(headers), + application=application, + before_receive=unexpected_read, + ) + assert status == 429 + assert json.loads(response_body)["error_code"] == "server_busy" + assert _header(response_headers, b"retry-after") == "1" + _assert_api_no_store(response_headers) + finally: + release.set() + await first + + status, _, response_body = await _call_app_raw( + "/api/keys/generate", + body=body, + headers=_with_api_token(headers), + application=application, + ) + assert status == 400 + assert json.loads(response_body)["error_code"] == "weak_password" + + asyncio.run(exercise()) + + +@pytest.mark.parametrize("cancel_request", [False, True]) +def test_service_remains_responsive_until_crypto_worker_finishes(monkeypatch, tmp_path, cancel_request): + (tmp_path / "index.html").write_text("local interface", encoding="utf-8") + monkeypatch.setattr(api_app, "STATIC_APP_DIR", tmp_path) + monkeypatch.setattr(core, "inspect_key_pem_strict", lambda _pem: {"key_type": "public", "kem": cfg.HYBRID_KEM_ALG}) + + async def exercise(): + loop = asyncio.get_running_loop() + event_loop_thread = threading.get_ident() + started = asyncio.Event() + unblock = threading.Event() + + def blocked_generation(_password): + loop.call_soon_threadsafe(started.set) + assert unblock.wait(timeout=5) + return {"publicPem": "public", "privatePem": "encrypted private"} + + def health_payload(): + assert threading.get_ident() != event_loop_thread + return {"backendReady": True} + + monkeypatch.setattr(api_app, "_generate_key_pair", blocked_generation) + monkeypatch.setattr(api_app, "_health_payload", health_payload) + async with _running_app() as application: + body, headers = _urlencoded_body({"password": "correct horse battery staple"}) + first = asyncio.create_task( + _call_app_raw( + "/api/keys/generate", body=body, headers=_with_api_token(headers), application=application + ) + ) + try: + await asyncio.wait_for(started.wait(), timeout=2) + health_status, _, health_body = await asyncio.wait_for( + _call_app_raw("/api/health", method="GET", application=application), timeout=2 + ) + assert health_status == 200 + assert json.loads(health_body)["backendReady"] is True + static_status, _, static_body = await asyncio.wait_for( + _call_app_raw("/", method="GET", application=application), timeout=2 + ) + assert static_status == 200 + assert static_body == b"local interface" + inspect_body, inspect_headers = _multipart_body("key", "public.pem", b"public") + inspect_status, _, _ = await asyncio.wait_for( + _call_app_raw( + "/api/keys/inspect", + body=inspect_body, + headers=_with_api_token(inspect_headers), + application=application, + ), + timeout=2, + ) + assert inspect_status == 200 + if cancel_request: + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + + status, _, _ = await _call_app_raw( + "/api/keys/generate", body=body, headers=_with_api_token(headers), application=application + ) + assert status == 429 + unblock.set() + if not cancel_request: + status, _, _ = await asyncio.wait_for(first, timeout=2) + assert status == 200 + + async def retry_when_worker_finishes(): + while True: + status, _, _ = await _call_app_raw( + "/api/keys/generate", body=body, headers=_with_api_token(headers), application=application + ) + if status != 429: + return status + await asyncio.sleep(0.001) + + assert await asyncio.wait_for(retry_when_worker_finishes(), timeout=2) == 200 + finally: + unblock.set() + if not first.done(): + await asyncio.wait_for(first, timeout=2) + + asyncio.run(exercise()) + + def test_health_payload_reports_partial_capabilities(monkeypatch): def missing_current_backend(_kem): raise core.CryptoDependencyError("private backend detail") @@ -72,6 +268,74 @@ def missing_current_backend(_kem): assert "private backend detail" not in str(payload) +def test_crypto_admission_is_retained_until_response_is_sent(monkeypatch): + monkeypatch.setattr( + api_app, "_generate_key_pair", lambda _password: {"publicPem": "public", "privatePem": "private"} + ) + + async def exercise(): + async with _running_app() as application: + sending = asyncio.Event() + release = asyncio.Event() + + async def hold_response(message): + if message["type"] == "http.response.body": + sending.set() + await release.wait() + + body, headers = _urlencoded_body({"password": "correct horse battery staple"}) + first = asyncio.create_task( + _call_app_raw( + "/api/keys/generate", + body=body, + headers=_with_api_token(headers), + application=application, + before_send=hold_response, + ) + ) + try: + await asyncio.wait_for(sending.wait(), timeout=2) + status, _, _ = await _call_app_raw( + "/api/keys/generate", body=body, headers=_with_api_token(headers), application=application + ) + assert status == 429 + finally: + release.set() + await first + status, _, _ = await _call_app_raw( + "/api/keys/generate", body=body, headers=_with_api_token(headers), application=application + ) + assert status == 200 + + asyncio.run(exercise()) + + +@pytest.mark.parametrize("failure", ["parse", "worker"]) +def test_crypto_admission_recovers_after_request_failure(monkeypatch, failure): + def generate(_password): + if failure == "worker": + raise RuntimeError("test worker failure") + return {"publicPem": "public", "privatePem": "private"} + + monkeypatch.setattr(api_app, "_generate_key_pair", generate) + + async def exercise(): + async with _running_app() as application: + body, headers = _urlencoded_body({} if failure == "parse" else {"password": "test"}) + status, _, _ = await _call_app_raw( + "/api/keys/generate", body=body, headers=_with_api_token(headers), application=application + ) + assert status == (400 if failure == "parse" else 500) + monkeypatch.setattr(api_app, "_generate_key_pair", lambda _password: {"publicPem": "public"}) + body, headers = _urlencoded_body({"password": "test"}) + status, _, _ = await _call_app_raw( + "/api/keys/generate", body=body, headers=_with_api_token(headers), application=application + ) + assert status == 200 + + asyncio.run(exercise()) + + def test_content_disposition_quotes_download_filename(): header = api_app._content_disposition("encrypted file.pqc") @@ -149,8 +413,11 @@ async def _call_app_raw( *, host: str | None = None, expected_exception: type[Exception] | None = None, + application: ASGIApp | None = None, + before_receive: Callable[[], Awaitable[None]] | None = None, + before_send: Callable[[dict[str, Any]], Awaitable[None]] | None = None, ) -> tuple[int, list[tuple[bytes, bytes]], bytes]: - app = api_app.create_app() + app = application sent: list[dict[str, Any]] = [] request_sent = False request_headers = list(headers) if headers is not None else [(b"content-length", str(len(body)).encode("ascii"))] @@ -164,9 +431,13 @@ async def receive(): if request_sent: return {"type": "http.disconnect"} request_sent = True + if before_receive is not None: + await before_receive() return {"type": "http.request", "body": body, "more_body": False} async def send(message): + if before_send is not None: + await before_send(message) sent.append(message) scope = { @@ -184,7 +455,10 @@ async def send(message): } expected_exception_raised = False try: - await app(scope, receive, send) + async with AsyncExitStack() as lifecycle: + if app is None: + app = await lifecycle.enter_async_context(_running_app()) + await app(scope, receive, send) except Exception as exc: if expected_exception is None or not isinstance(exc, expected_exception): raise @@ -199,6 +473,14 @@ async def send(message): return status, response_headers, response_body +@asynccontextmanager +async def _running_app() -> AsyncIterator[ASGIApp]: + app = api_app.create_app() + inner = cast(Starlette, cast(api_app.SecurityHeadersMiddleware, app).app) + async with inner.router.lifespan_context(inner): + yield app + + async def _call_app( path: str, method: str = "POST", diff --git a/tests/test_api_worker.py b/tests/test_api_worker.py new file mode 100644 index 0000000..4a58d65 --- /dev/null +++ b/tests/test_api_worker.py @@ -0,0 +1,188 @@ +import asyncio +import threading + +import pytest + +from api_worker import CryptoWorker + + +async def _acquire_when_ready(worker): + async def poll(): + while True: + lease = worker.acquire() + if lease is not None: + return lease + await asyncio.sleep(0.001) + + return await asyncio.wait_for(poll(), timeout=2) + + +def test_admission_is_exclusive_and_independent_for_each_worker(): + first_worker = CryptoWorker() + second_worker = CryptoWorker() + try: + first = first_worker.acquire() + second = second_worker.acquire() + assert first is not None + assert second is not None + assert first_worker.acquire() is None + first.close() + replacement = first_worker.acquire() + assert replacement is not None + first.close() + assert first_worker.acquire() is None + replacement.close() + second.close() + finally: + first_worker.close() + second_worker.close() + + +def test_work_runs_off_event_loop_and_admission_lasts_until_request_closes(): + async def scenario(): + worker = CryptoWorker() + try: + lease = worker.acquire() + assert lease is not None + assert await lease.run(threading.get_ident) != threading.get_ident() + assert await lease.run(bytes.join, b"-", [b"first", b"second"]) == b"first-second" + assert worker.acquire() is None + lease.close() + with pytest.raises(RuntimeError): + await lease.run(lambda: None) + replacement = worker.acquire() + assert replacement is not None + replacement.close() + finally: + worker.close() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("cancel_queued", [False, True]) +def test_cancelled_request_holds_admission_until_running_worker_finishes(cancel_queued): + async def scenario(): + worker = CryptoWorker() + unblock = threading.Event() + started = asyncio.Event() + loop = asyncio.get_running_loop() + + def blocked_operation(): + loop.call_soon_threadsafe(started.set) + assert unblock.wait(timeout=3) + return b"completed" + + try: + lease = worker.acquire() + assert lease is not None + running = asyncio.create_task(lease.run(blocked_operation)) + await asyncio.wait_for(started.wait(), timeout=2) + if cancel_queued: + queued = asyncio.create_task(lease.run(lambda: pytest.fail("cancelled queued work must not run"))) + await asyncio.sleep(0) + queued.cancel() + with pytest.raises(asyncio.CancelledError): + await queued + running.cancel() + with pytest.raises(asyncio.CancelledError): + await running + lease.close() + lease.close() + assert worker.acquire() is None + unblock.set() + replacement = await _acquire_when_ready(worker) + assert await replacement.run(lambda: b"next") == b"next" + replacement.close() + finally: + unblock.set() + worker.close() + + asyncio.run(scenario()) + + +def test_callable_failure_propagates_and_request_close_restores_admission(): + def fail(): + raise ValueError("operation failed") + + async def scenario(): + worker = CryptoWorker() + try: + lease = worker.acquire() + assert lease is not None + with pytest.raises(ValueError, match="operation failed"): + await lease.run(fail) + assert worker.acquire() is None + lease.close() + replacement = worker.acquire() + assert replacement is not None + replacement.close() + finally: + worker.close() + + asyncio.run(scenario()) + + +def test_submit_failure_does_not_leak_admission(monkeypatch): + def fail_submit(*_args, **_kwargs): + raise RuntimeError("executor unavailable") + + async def scenario(): + worker = CryptoWorker() + try: + lease = worker.acquire() + assert lease is not None + with monkeypatch.context() as context: + context.setattr(worker._executor, "submit", fail_submit) + with pytest.raises(RuntimeError, match="executor unavailable"): + await lease.run(lambda: None) + lease.close() + replacement = worker.acquire() + assert replacement is not None + assert await replacement.run(lambda: "recovered") == "recovered" + replacement.close() + finally: + worker.close() + + asyncio.run(scenario()) + + +def test_shutdown_stops_admission_and_waits_for_running_work(monkeypatch): + async def scenario(): + worker = CryptoWorker() + unblock = threading.Event() + started = asyncio.Event() + shutting_down = asyncio.Event() + loop = asyncio.get_running_loop() + original_shutdown = worker._executor.shutdown + + def tracked_shutdown(*args, **kwargs): + loop.call_soon_threadsafe(shutting_down.set) + return original_shutdown(*args, **kwargs) + + def blocked_operation(): + loop.call_soon_threadsafe(started.set) + assert unblock.wait(timeout=3) + return "finished" + + monkeypatch.setattr(worker._executor, "shutdown", tracked_shutdown) + try: + lease = worker.acquire() + assert lease is not None + operation = asyncio.create_task(lease.run(blocked_operation)) + await asyncio.wait_for(started.wait(), timeout=2) + shutdown = asyncio.create_task(asyncio.to_thread(worker.close)) + await asyncio.wait_for(shutting_down.wait(), timeout=2) + assert not shutdown.done() + assert worker.acquire() is None + with pytest.raises(RuntimeError): + await lease.run(lambda: None) + unblock.set() + assert await asyncio.wait_for(operation, timeout=2) == "finished" + await asyncio.wait_for(shutdown, timeout=2) + lease.close() + assert worker.acquire() is None + finally: + unblock.set() + worker.close() + + asyncio.run(scenario()) diff --git a/tests/test_dependency_locks.py b/tests/test_dependency_locks.py index 9fe2eab..176fd32 100644 --- a/tests/test_dependency_locks.py +++ b/tests/test_dependency_locks.py @@ -12,8 +12,8 @@ "distribution": "ubuntu", "distribution-version": "24.04", "python": "3.13.15", - "pip": "25.3", - "pip-tools": "7.5.3", + "pip": "26.2.1", + "pip-tools": "7.6.1", "packaging": "26.2", "build": "1.5.0", "click": "8.4.2", @@ -123,8 +123,8 @@ def test_manifest_parity_rejects_duplicate_project_dependency(tmp_path): ("distribution", "alpine"), ("distribution-version", "24.10"), ("python", "3.13.14"), - ("pip", "26.0"), - ("pip-tools", "7.5.2"), + ("pip", "26.1.2"), + ("pip-tools", "7.5.3"), ("packaging", "26.1"), ("build", "1.4.0"), ("click", "8.3.0"), diff --git a/web/src/features/generate/GenerateKeysWorkflow.test.tsx b/web/src/features/generate/GenerateKeysWorkflow.test.tsx index d51cc6d..e74485b 100644 --- a/web/src/features/generate/GenerateKeysWorkflow.test.tsx +++ b/web/src/features/generate/GenerateKeysWorkflow.test.tsx @@ -222,6 +222,34 @@ describe("GenerateKeysWorkflow", () => { expect(screen.queryByText(/private socket detail/i)).not.toBeInTheDocument(); }); + it("shows server-busy guidance and preserves passwords for a manual retry", async () => { + const message = "The local service is processing another operation. Wait for it to finish, then try again."; + const generate = vi.fn() + .mockRejectedValueOnce(new ApiError(429, "server_busy", message)) + .mockResolvedValueOnce(generatedKeys()); + const user = userEvent.setup(); + + render(); + await enterValidPasswords(user); + await user.click(screen.getByRole("button", { name: "Generate key pair" })); + + expect(await screen.findByText(message)).toBeVisible(); + for (const label of ["Private key password", "Confirm private key password"]) { + expect(screen.getByLabelText(label)).toHaveValue("correct horse battery staple"); + expect(screen.getByLabelText(label)).toBeEnabled(); + } + expect(screen.getByRole("button", { name: "Generate key pair" })).toBeEnabled(); + expect(generate).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("button", { name: "Download public key" })).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Generate key pair" })); + + expect(await screen.findByRole("button", { name: "Download public key" })).toBeVisible(); + expect(generate).toHaveBeenCalledTimes(2); + expect(generate).toHaveBeenLastCalledWith("correct horse battery staple", expect.any(AbortSignal)); + expect(screen.queryByText(message)).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.")