Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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" \
Expand Down Expand Up @@ -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: >
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -291,6 +293,7 @@ jobs:
import crypto_config
import crypto_core
import api_app
import api_worker
import pqc_agent_tools
import ui_helpers

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ dmypy.json
*.swo

# Project specific
.oqs/
*.pem
*.pqc
test_files/
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
199 changes: 129 additions & 70 deletions api_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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")
Expand All @@ -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"]),
Expand All @@ -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)
Expand Down
Loading