Skip to content
Merged
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
19 changes: 13 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ A post-quantum cryptography tool for file encryption. New files combine ML-KEM-7
<a href="docs/SCREENSHOTS.md">
<img
src="docs/screenshots/custom-web-encrypt-workflow.png"
alt="Quantum Encryptor custom web app showing the Encrypt workflow and its technical details"
alt="Quantum Encryptor showing an expected recipient fingerprint matching the selected public key"
width="900"
>
</a>
</p>

<p align="center">
<strong>Monochrome local web interface for ML-KEM-768 + X25519 key generation, file encryption, decryption, and PEM key inspection.</strong>
<strong>Local file encryption with recipient fingerprint checks, batch processing, and large-file jobs.</strong>
</p>

## Features
Expand All @@ -26,6 +26,7 @@ A post-quantum cryptography tool for file encryption. New files combine ML-KEM-7
- **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
- **Public-Key Fingerprints**: Full versioned SHA3-256 identifiers support independent public-key comparison
- **Expected Recipient Checks**: Optionally require an independently obtained fingerprint before single-file, batch, large-file, or CLI encryption; the backend rejects a different public key
- **User-Friendly Interface**: Custom local web UI with progressive technical details and a Python ASGI API
- **Batch Encryption**: Encrypt up to 25 files for one recipient with sequential processing, per-file results, cancellation, and explicit downloads
- **Batch Decryption**: Restore up to 25 encrypted files with one private key and password, retaining successful results when another file fails
Expand All @@ -38,7 +39,7 @@ A post-quantum cryptography tool for file encryption. New files combine ML-KEM-7

## Screenshots

The current browser smoke captures show the responsive Encrypt and Inspect key workflows. Click either image to open the full screenshot page.
These captures show the real local app with sample files and freshly generated keys: recipient fingerprint comparison, a completed large-file job, and mobile key inspection. Open the gallery for full-size images.

<p>
<a href="docs/SCREENSHOTS.md#custom-web-encrypt-workflow">
Expand All @@ -49,6 +50,8 @@ The current browser smoke captures show the responsive Encrypt and Inspect key w
</a>
</p>

![Completed large-file encryption with an explicit result download](docs/screenshots/custom-web-large-file-result.png)

See [docs/SCREENSHOTS.md](docs/SCREENSHOTS.md) for the dedicated screenshot page.

## Project Documentation
Expand Down Expand Up @@ -152,6 +155,10 @@ The web app keeps its generated-result references in the current tab's in-memory

Successful key generation and validated public-key inspection return a complete fingerprint in the form `QE1-SHA3-256:<64 lowercase hexadecimal characters>`. The Generate workflow shows the fingerprint for the new pair, Inspect key shows it for a validated public key, and Encrypt shows the recipient fingerprint before encryption. Compare the entire value with the key owner over an independently authenticated channel, separate from the channel that delivered the key.

To enforce that comparison, paste the independently obtained value into **Expected recipient fingerprint (optional)** in Encrypt, Batch encrypt, or Large files. A malformed or different value blocks submission, and the backend checks it again against the actual key before encryption. Changing the selected public key keeps your expectation so a substitution cannot silently clear the check. Leaving the field empty preserves ordinary encryption. This feature requires an engine advertising recipient-fingerprint support; a supplied expectation cannot be used with an older engine.

For the CLI, add `--expected-recipient-fingerprint "$RECIPIENT_FINGERPRINT"` to `encrypt`, where `RECIPIENT_FINGERPRINT` contains the independently obtained complete value. A malformed or mismatching expectation fails before creating an output or replacing an existing destination.

A matching fingerprint identifies the same validated algorithm label and canonical public-key bytes. It does not prove the owner's identity or control of the private key, certify that the key is trustworthy, or protect a comparison performed through the same compromised channel. Fingerprints are public identifiers and do not change the PEM or encrypted-file formats. Metadata-only inspection of an encrypted private key omits the fingerprint because deriving it requires an authenticated password unlock.

## Verification
Expand Down Expand Up @@ -184,7 +191,7 @@ When a native `liboqs` installation is available to the running app, also run th
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.
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, recipient checks, encryption/decryption, key recovery/password changes, file verification, and large-file jobs through the available native backend. To refresh the committed screenshots during that same run, use `npm run ui-native -- --screenshots`. See [the screenshot capture notes](docs/SCREENSHOTS.md#refreshing-the-images).

### Key Generation

Expand All @@ -198,7 +205,7 @@ Do not treat the browser smoke test as proof that the native cryptographic backe
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. Compare the complete recipient fingerprint over an independently authenticated channel
4. Obtain the complete fingerprint over an independently authenticated channel and optionally paste it into **Expected recipient fingerprint** to enforce a match
5. Specify the output filename
6. Download the encrypted file

Expand All @@ -213,7 +220,7 @@ Do not treat the browser smoke test as proof that the native cryptographic backe
### Batch Encryption

1. Choose **Batch encrypt** and select or drop up to 25 files. Their combined plaintext size must fit the displayed file limit (100 MiB by default).
2. Select the recipient's public key and compare its complete fingerprint over an independently authenticated channel.
2. Select the recipient's public key. Optionally enter its independently obtained complete fingerprint to require a match for every file in the batch.
3. Choose **Encrypt batch**. Files run one at a time, with separate progress and error states. A failed file does not discard successful results or automatically retry the failed request.
4. Download each completed result. Batch output names retain the original extension, such as `report.pdf.pqc`; duplicate names receive a numeric suffix.
5. Choose **Clear batch** when finished. Results live only in the current tab; there is no persistent batch history or server-side recovery.
Expand Down
46 changes: 40 additions & 6 deletions api_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

from starlette.applications import Starlette
from starlette.concurrency import run_in_threadpool
from starlette.datastructures import UploadFile
from starlette.datastructures import FormData, UploadFile
from starlette.requests import Request
from starlette.responses import JSONResponse, PlainTextResponse, Response, StreamingResponse
from starlette.routing import BaseRoute, Mount, Route
Expand Down Expand Up @@ -533,6 +533,15 @@ def _form_text(form: Any, name: str, required: bool = True) -> str:
return str(value)


def _form_recipient_fingerprint(form: FormData) -> str | None:
values = form.getlist("expected_recipient_fingerprint")
if not values:
return None
if len(values) != 1 or not isinstance(values[0], str):
raise core.InvalidRecipientFingerprintError("Provide the complete canonical recipient fingerprint.")
return core.validate_recipient_fingerprint(values[0])


def _form_upload(form: Any, name: str) -> UploadFile:
value = form.get(name)
if not isinstance(value, UploadFile):
Expand Down Expand Up @@ -617,6 +626,7 @@ def _health_payload() -> dict[str, Any]:
"supportsKeyPasswordChange": True,
"supportsPublicKeyRecovery": True,
"supportsFileVerification": True,
"supportsRecipientFingerprint": True,
"backendReady": current_backend_ready,
"backendMessage": backend_message,
"capabilities": capabilities,
Expand Down Expand Up @@ -968,7 +978,7 @@ async def verify_file(request: Request) -> JSONResponse:
await request.close()


def _encrypt_bytes(input_data: bytes, public_pem: str) -> bytes:
def _encrypt_bytes(input_data: bytes, public_pem: str, expected_recipient_fingerprint: str | None = None) -> 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.")
Expand All @@ -979,6 +989,8 @@ def _encrypt_bytes(input_data: bytes, public_pem: str) -> bytes:
"Generate a new ML-KEM-768+X25519-v2 public key for encryption.",
)

if expected_recipient_fingerprint is not None:
core.verify_recipient_fingerprint(public_key_bytes, kem_alg_from_key, expected_recipient_fingerprint)
encrypted_blob = core.encrypt_file_pro(input_data, public_key_bytes, kem_alg_from_key)
del input_data
del public_key_bytes
Expand All @@ -990,7 +1002,8 @@ def _encrypt_bytes(input_data: bytes, public_pem: str) -> bytes:

async def encrypt_file(request: Request) -> Response:
try:
form = await _form(request, max_files=2)
form = await _form(request, max_files=3)
expected_recipient_fingerprint = _form_recipient_fingerprint(form)
uploaded_file = _form_upload(form, "file")
public_key_file = _form_upload(form, "public_key")
original_filename = Path(uploaded_file.filename or "file")
Expand All @@ -1001,12 +1014,26 @@ 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")
encrypted_blob = await request.state.crypto_lease.run(_encrypt_bytes, input_data, public_pem)
encrypted_blob = await request.state.crypto_lease.run(
_encrypt_bytes, input_data, public_pem, expected_recipient_fingerprint
)
del input_data

return _download_response(encrypted_blob, output_filename)
except ApiError as exc:
return _json_error(exc)
except core.InvalidRecipientFingerprintError:
return _json_error(
ApiError(400, "invalid_recipient_fingerprint", "Provide the complete canonical recipient fingerprint.")
)
except core.RecipientFingerprintMismatchError:
return _json_error(
ApiError(
400,
"recipient_fingerprint_mismatch",
"The public key does not match the expected recipient fingerprint.",
)
)
except core.CryptoDependencyError:
return _json_error(ApiError(503, "backend_unavailable", "Post-quantum backend is not ready."))
except Exception as exc:
Expand Down Expand Up @@ -1080,6 +1107,8 @@ def _job_error(exc: Exception) -> JSONResponse:
error = ApiError(exc.status, exc.code, exc.message)
elif isinstance(exc, ApiError):
error = exc
elif isinstance(exc, core.InvalidRecipientFingerprintError):
error = ApiError(400, "invalid_recipient_fingerprint", "Provide the complete canonical recipient fingerprint.")
elif isinstance(exc, RequestBodyTooLarge):
error = ApiError(413, "request_too_large", "Request body exceeds the configured size limit.")
elif isinstance(exc, OSError):
Expand Down Expand Up @@ -1136,11 +1165,16 @@ async def upload_job(request: Request) -> JSONResponse:
async def start_job(request: Request) -> JSONResponse:
try:
jobs, job = _request_job(request)
form = await _form(request, max_files=1, max_fields=1)
form = await _form(request, max_files=2, max_fields=3)
expected_recipient_fingerprint = _form_recipient_fingerprint(form)
if expected_recipient_fingerprint is not None and job.mode != "encrypt":
raise core.InvalidRecipientFingerprintError("Recipient verification applies only to encryption.")
pem = await _read_upload_text(_form_upload(form, "key"), cfg.MAX_PEM_BYTES, "Key file")
password = "" if job.mode == "encrypt" else _workflow_password(form)
filename = f"{job.filename}.pqc" if job.mode == "encrypt" else guess_decrypted_filename(Path(job.filename))
jobs.start(job, pem, password, sanitize_download_filename(filename, "download.bin"))
jobs.start(
job, pem, password, sanitize_download_filename(filename, "download.bin"), expected_recipient_fingerprint
)
return _success_json({"job": job.snapshot()})
except Exception as exc:
return _job_error(exc)
Expand Down
56 changes: 50 additions & 6 deletions api_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,15 +236,35 @@ async def upload(self, job: FileJob, chunks: Any) -> None:
finally:
job.upload_task = None

def start(self, job: FileJob, pem: str, password: str, output_filename: str) -> None:
def start(
self,
job: FileJob,
pem: str,
password: str,
output_filename: str,
expected_recipient_fingerprint: str | None = None,
) -> None:
if job.state != "ready":
raise JobError(409, "invalid_job_state", "Upload a complete file before starting this job.")
if expected_recipient_fingerprint is not None:
core.validate_recipient_fingerprint(expected_recipient_fingerprint)
if job.mode != "encrypt":
raise core.InvalidRecipientFingerprintError("Recipient verification applies only to encryption.")
job.state = "running"
job.phase = "preparing"
job.processed = 0
job.task = asyncio.create_task(self._execute(job, pem, password, output_filename))

def _process(self, job: FileJob, pem: str, password: str, output_filename: str) -> None:
job.task = asyncio.create_task(
self._execute(job, pem, password, output_filename, expected_recipient_fingerprint)
)

def _process(
self,
job: FileJob,
pem: str,
password: str,
output_filename: str,
expected_recipient_fingerprint: str | None = None,
) -> None:
if job.cancel.is_set():
raise stream.OperationCancelled()
info = core.inspect_key_pem_strict(pem)
Expand All @@ -255,6 +275,8 @@ def _process(self, job: FileJob, pem: str, password: str, output_filename: str)
if raw is None or algorithm is None or key_type != expected_type:
raise JobError(400, "private_key_failed", "Could not unlock the private key. Check its password and file.")
try:
if expected_recipient_fingerprint is not None:
core.verify_recipient_fingerprint(raw, algorithm, expected_recipient_fingerprint)
if job.input is None:
raise RuntimeError("Input storage is unavailable.")
if job.mode == "verify":
Expand Down Expand Up @@ -289,16 +311,38 @@ def _process(self, job: FileJob, pem: str, password: str, output_filename: str)
finally:
del raw

async def _execute(self, job: FileJob, pem: str, password: str, output_filename: str) -> None:
async def _execute(
self,
job: FileJob,
pem: str,
password: str,
output_filename: str,
expected_recipient_fingerprint: str | None = None,
) -> None:
try:
await job.lease.run_owned(self._process, job, pem, password, output_filename, on_cancel=job.cancel.set)
await job.lease.run_owned(
self._process,
job,
pem,
password,
output_filename,
expected_recipient_fingerprint,
on_cancel=job.cancel.set,
)
job.state = "cancelled" if job.cancel.is_set() else "complete"
except (stream.OperationCancelled, asyncio.CancelledError):
job.state = "cancelled"
except Exception as exc:
job.state = "failed"
if isinstance(exc, JobError):
code, message = exc.code, exc.message
elif isinstance(exc, core.InvalidRecipientFingerprintError):
code, message = "invalid_recipient_fingerprint", "Provide the complete canonical recipient fingerprint."
elif isinstance(exc, core.RecipientFingerprintMismatchError):
code, message = (
"recipient_fingerprint_mismatch",
"The public key does not match the expected recipient fingerprint.",
)
elif isinstance(exc, core.CryptoDependencyError):
code, message = "backend_unavailable", "The post-quantum backend is unavailable."
elif isinstance(exc, OSError):
Expand Down
29 changes: 29 additions & 0 deletions crypto_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import binascii
import ctypes.util
import importlib
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Tuple, Dict, Any, Protocol
Expand Down Expand Up @@ -81,6 +82,14 @@ class InvalidKeyFormatError(ValueError):
"""Raised when a key file is malformed or semantically invalid."""


class InvalidRecipientFingerprintError(ValueError):
"""Raised when an expected recipient fingerprint is not canonical."""


class RecipientFingerprintMismatchError(ValueError):
"""Raised when the loaded public key differs from the expected recipient."""


@dataclass(frozen=True)
class EncryptedFileMetadata:
"""Non-secret encrypted-container metadata."""
Expand Down Expand Up @@ -342,6 +351,26 @@ def get_public_key_fingerprint(key_bytes: bytes, kem_alg: str) -> str:
return f"QE1-SHA3-256:{hashlib.sha3_256(fingerprint_input).hexdigest()}"


def validate_recipient_fingerprint(expected_fingerprint: str) -> str:
"""Require a complete canonical fingerprint without silently normalizing input."""
if (
not isinstance(expected_fingerprint, str)
or re.fullmatch(r"QE1-SHA3-256:[0-9a-f]{64}", expected_fingerprint) is None
):
raise InvalidRecipientFingerprintError("Provide the complete canonical recipient fingerprint.")
return expected_fingerprint


def verify_recipient_fingerprint(public_key: bytes, kem_alg: str, expected_fingerprint: str | None = None) -> str:
"""Compare an independently supplied fingerprint with the actual validated key."""
if expected_fingerprint is not None:
validate_recipient_fingerprint(expected_fingerprint)
actual_fingerprint = get_public_key_fingerprint(public_key, kem_alg)
if expected_fingerprint is not None and not hmac.compare_digest(actual_fingerprint, expected_fingerprint):
raise RecipientFingerprintMismatchError("The public key does not match the expected recipient fingerprint.")
return actual_fingerprint


def get_public_key_from_private(private_key_bytes: bytes, kem_alg: str) -> bytes:
"""Recover canonical public bytes from validated private-key material."""
_validate_key_material(private_key_bytes, kem_alg, "private")
Expand Down
Loading
Loading