diff --git a/README.md b/README.md
index c788828..08c715d 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
- Monochrome local web interface for ML-KEM-768 + X25519 key generation, file encryption, decryption, and PEM key inspection.
+ Local file encryption with recipient fingerprint checks, batch processing, and large-file jobs.
## Features
@@ -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
@@ -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.
@@ -49,6 +50,8 @@ The current browser smoke captures show the responsive Encrypt and Inspect key w
+
+
See [docs/SCREENSHOTS.md](docs/SCREENSHOTS.md) for the dedicated screenshot page.
## Project Documentation
@@ -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
@@ -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
@@ -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
@@ -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.
diff --git a/api_app.py b/api_app.py
index 92b0864..3f9f20c 100644
--- a/api_app.py
+++ b/api_app.py
@@ -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
@@ -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):
@@ -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,
@@ -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.")
@@ -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
@@ -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")
@@ -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:
@@ -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):
@@ -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)
diff --git a/api_jobs.py b/api_jobs.py
index 6824b6b..71f3d32 100644
--- a/api_jobs.py
+++ b/api_jobs.py
@@ -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)
@@ -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":
@@ -289,9 +311,24 @@ 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"
@@ -299,6 +336,13 @@ async def _execute(self, job: FileJob, pem: str, password: str, output_filename:
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):
diff --git a/crypto_core.py b/crypto_core.py
index 1b33e9d..21696d2 100644
--- a/crypto_core.py
+++ b/crypto_core.py
@@ -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
@@ -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."""
@@ -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")
diff --git a/docs/API.md b/docs/API.md
index 63aef3f..5d82695 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -62,6 +62,8 @@ 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.
+`supportsRecipientFingerprint: true` advertises server enforcement of optional expected-recipient fingerprints on single-file encryption and encrypt-mode job starts. Clients offering this check must fetch current health and require this flag before submitting an expectation, including after an authentication refresh before retrying. Older servers may ignore unknown form fields; a stale UI capability is insufficient. The web client rejects unsupported protected requests with `recipient_fingerprint_unsupported`.
+
### 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.
@@ -78,7 +80,7 @@ All job routes require the existing local authentication and origin checks. IDs
| --- | --- | --- |
| `/api/jobs` | Multipart `mode` (`encrypt`, `decrypt`, `verify`), `filename`, decimal `size` | `{ok: true, job}` reservation |
| `/api/jobs/{id}/upload` | Raw `application/octet-stream`, exactly the reserved size | `{ok: true, job}` |
-| `/api/jobs/{id}/start` | Multipart PEM file `key`, plus `password` for private-key operations | `{ok: true, job}` |
+| `/api/jobs/{id}/start` | Multipart PEM file `key`, plus `password` for private-key operations; optional `expected_recipient_fingerprint` for encryption | `{ok: true, job}` |
| `/api/jobs/{id}/status` | Empty body | `{ok: true, job}` |
| `/api/jobs/{id}/cancel` | Empty body | `{ok: true, job}`; active work may remain `cancelling` |
| `/api/jobs/{id}/clear` | Empty body | `{ok: true}` after cleanup; `409` while work/download owns files |
@@ -110,6 +112,14 @@ The agent `generate-keys` and public `inspect-key` results use the JSON field `p
Compare the complete fingerprint over an independently authenticated channel separate from key delivery. Equality identifies the same validated algorithm label and canonical public bytes, but it is not a certificate, signature, trust chain, proof of identity, proof of private-key control, or protection against a channel that substitutes both the key and comparison value.
+### Expected-recipient encryption
+
+`POST /api/files/encrypt` and encrypt-mode `POST /api/jobs/{id}/start` accept the optional multipart text field `expected_recipient_fingerprint`. It must contain exactly the canonical complete representation above. Absent means no expected-recipient check; a supplied empty value, malformed value, duplicate field, or uploaded file in place of text returns `400 invalid_recipient_fingerprint`. The field is rejected on decrypt/verify jobs rather than ignored.
+
+The service recomputes the fingerprint from the decoded public key used for encryption and compares the complete values before encryption or output creation. Single-file mismatches return `400 recipient_fingerprint_mismatch`. Large-file mismatches fail the job with `error.code: recipient_fingerprint_mismatch`; no encrypted result is published. The expectation is captured at job start and applies to that job's key. Batch encryption sends the same captured expectation with every file request. Existing PEM and ciphertext formats are unchanged.
+
+The CLI exposes the same check as `encrypt --expected-recipient-fingerprint VALUE`; omitted preserves existing behavior, while explicitly empty is invalid. Errors use `invalid_recipient_fingerprint` or `recipient_fingerprint_mismatch` and leave output destinations unchanged. Successful encryption includes `public_key_fingerprint`, the actual recipient key's identifier. Do not derive the expected value from the key being checked: supply the independently authenticated comparison value.
+
### Agent JSON Contract
Successful command output:
@@ -348,6 +358,11 @@ def load_key_pem(
def get_public_key_fingerprint(key_bytes: bytes, kem_alg: str) -> str:
"""Validate canonical public-key bytes and return their versioned SHA3-256 fingerprint."""
+def verify_recipient_fingerprint(
+ public_key: bytes, kem_alg: str, expected_fingerprint: str | None = None
+) -> str:
+ """Return the actual fingerprint; reject a malformed or mismatching supplied expectation."""
+
def get_private_key_public_fingerprint(private_key_bytes: bytes, kem_alg: str) -> str:
"""Derive and fingerprint the public key from already authenticated private-key bytes."""
```
diff --git a/docs/SCREENSHOTS.md b/docs/SCREENSHOTS.md
index 7acda34..759be83 100644
--- a/docs/SCREENSHOTS.md
+++ b/docs/SCREENSHOTS.md
@@ -1,11 +1,29 @@
# Application Screenshots
-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.
+These screenshots were captured from the real local web app with an available native cryptographic backend, sample files, and a freshly generated demonstration key pair. No API responses or successful operations are mocked. Public fingerprints shown here belong to disposable demonstration keys.
## Custom Web Encrypt Workflow
-
+The demonstration expected fingerprint matches the selected public key. Both the interface and backend reject a mismatch before encryption. In real use, obtain the expected value through an independently authenticated channel. Equality identifies the same key; it does not certify the recipient's identity.
+
+
+
+## Large-file Encryption Result
+
+A real completed encryption job retains its result for an explicit download. The native browser check also decrypts it and compares the recovered bytes with the sample input.
+
+
## Custom Web Mobile Inspect

+
+## Refreshing the Images
+
+Build and start the app using the [README setup instructions](../README.md), with native encryption available. In a second terminal run:
+
+```bash
+npm run ui-native -- --screenshots
+```
+
+This runs the native workflow checks and overwrites these three images at desktop and mobile sizes. Captured pages also undergo automated accessibility and horizontal-overflow checks. Private keys and sample payloads live in an automatically cleaned temporary directory; no private PEM or password appears in the images. `UI_NATIVE_URL` selects another local app URL. If the installed Playwright browser is unavailable, `UI_BROWSER_EXECUTABLE` can select an existing compatible Chromium executable.
diff --git a/docs/screenshots/custom-web-encrypt-workflow.png b/docs/screenshots/custom-web-encrypt-workflow.png
index 9a423ed..7bdfcd5 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-large-file-result.png b/docs/screenshots/custom-web-large-file-result.png
new file mode 100644
index 0000000..ba9cae6
Binary files /dev/null and b/docs/screenshots/custom-web-large-file-result.png differ
diff --git a/docs/screenshots/custom-web-mobile-inspect.png b/docs/screenshots/custom-web-mobile-inspect.png
index a27f59d..a00243f 100644
Binary files a/docs/screenshots/custom-web-mobile-inspect.png and b/docs/screenshots/custom-web-mobile-inspect.png differ
diff --git a/pqc_agent_tools.py b/pqc_agent_tools.py
index 16d9117..6f5393c 100644
--- a/pqc_agent_tools.py
+++ b/pqc_agent_tools.py
@@ -531,6 +531,20 @@ def _password_from_env(env_name: str, operation: str, required: bool) -> Optiona
def _agent_error_from_core(operation: str, exc: Exception) -> AgentCommandError:
if isinstance(exc, streaming.OperationCancelled):
return AgentCommandError("cancelled", "Operation cancelled.", EXIT_CRYPTO_FAILURE, operation)
+ if isinstance(exc, core.InvalidRecipientFingerprintError):
+ return AgentCommandError(
+ "invalid_recipient_fingerprint",
+ "Expected recipient fingerprint must be a complete QE1-SHA3-256 fingerprint.",
+ EXIT_INVALID_INPUT,
+ operation,
+ )
+ if isinstance(exc, core.RecipientFingerprintMismatchError):
+ return AgentCommandError(
+ "recipient_fingerprint_mismatch",
+ "The public key does not match the expected recipient fingerprint. Encryption was not started.",
+ EXIT_INVALID_INPUT,
+ operation,
+ )
if isinstance(exc, core.AuthenticationFailedError):
return AgentCommandError(
"verification_failed" if operation == "verify-file" else "decryption_failed",
@@ -733,6 +747,17 @@ def handle_encrypt(args: argparse.Namespace, workspace: Path) -> int:
EXIT_INVALID_INPUT,
operation,
)
+ try:
+ public_key_fingerprint = core.verify_recipient_fingerprint(
+ public_key, kem_alg, args.expected_recipient_fingerprint
+ )
+ except (
+ core.InvalidRecipientFingerprintError,
+ core.RecipientFingerprintMismatchError,
+ core.InvalidKeyFormatError,
+ core.UnsupportedAlgorithmError,
+ ) as exc:
+ raise _agent_error_from_core(operation, exc) from exc
_resolve_backend(operation)
def write_encrypted(sink: BinaryIO, key: bytes = public_key) -> core.EncryptedFileMetadata:
@@ -757,6 +782,7 @@ def write_encrypted(sink: BinaryIO, key: bytes = public_key) -> core.EncryptedFi
kem=metadata.kem_alg,
input=_relative_to_workspace(input_path, workspace),
public_key=_relative_to_workspace(public_key_path, workspace),
+ public_key_fingerprint=public_key_fingerprint,
output=_relative_to_workspace(output_path, workspace),
bytes_written=metadata.total_bytes,
)
@@ -940,6 +966,11 @@ def build_parser() -> argparse.ArgumentParser:
encrypt = subparsers.add_parser("encrypt", help="Encrypt a workspace file.")
encrypt.add_argument("--input", required=True)
encrypt.add_argument("--public-key", required=True)
+ encrypt.add_argument(
+ "--expected-recipient-fingerprint",
+ default=None,
+ help="Require the public key to match this complete fingerprint from a separately trusted source.",
+ )
encrypt.add_argument("--output", required=True)
encrypt.add_argument("--overwrite", action="store_true")
_add_stream_limit_argument(encrypt)
diff --git a/scripts/ui_native_smoke.mjs b/scripts/ui_native_smoke.mjs
index ce21e5c..68bffef 100644
--- a/scripts/ui_native_smoke.mjs
+++ b/scripts/ui_native_smoke.mjs
@@ -1,14 +1,94 @@
import assert from "node:assert/strict";
-import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
+import { fileURLToPath } from "node:url";
+import AxeBuilder from "@axe-core/playwright";
import { chromium } from "playwright";
const baseUrl = process.env.UI_NATIVE_URL ?? "http://127.0.0.1:4000/";
const password = "correct horse battery staple";
const updatedPassword = "new correct horse battery staple";
const inputBytes = Buffer.from("native browser round trip");
+const screenshotDirectory = process.argv.includes("--screenshots")
+ ? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../docs/screenshots")
+ : null;
+
+async function capture(page, filename) {
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true,
+ "The workflow must fit its viewport without horizontal scrolling.");
+ const accessibility = await new AxeBuilder({ page }).analyze();
+ assert.deepEqual(accessibility.violations.map(({ id, impact }) => ({ id, impact })), [],
+ "The native workflow must pass automated accessibility checks.");
+ if (screenshotDirectory) {
+ await mkdir(screenshotDirectory, { recursive: true });
+ await page.screenshot({ path: path.join(screenshotDirectory, filename), fullPage: true });
+ }
+}
+
+async function selectExpectedRecipient(page, fingerprint, submitLabel) {
+ const input = page.getByLabel("Expected recipient fingerprint (optional)", { exact: true });
+ const submit = page.getByRole("button", { name: submitLabel, exact: true });
+ const different = `${fingerprint.slice(0, -1)}${fingerprint.endsWith("0") ? "1" : "0"}`;
+ await input.fill(fingerprint);
+ await page.getByText("Expected fingerprint matches the selected public key.", { exact: true }).waitFor();
+ assert.equal(await submit.isEnabled(), true, "The matching key must be ready before testing a mismatch.");
+ await input.fill(different);
+ await page.getByText("The expected fingerprint does not match the selected public key. Check the key and trusted fingerprint before encrypting.", { exact: true }).waitFor();
+ assert.equal(await submit.isDisabled(), true, "A mismatching expected recipient must block submission.");
+ await input.fill(fingerprint);
+ await page.getByText("Expected fingerprint matches the selected public key.", { exact: true }).waitFor();
+ assert.equal(await submit.isEnabled(), true, "A matching expected recipient must permit submission.");
+}
+
+async function checkServerRecipientGuard(page, publicKeyPath, fingerprint) {
+ const different = `${fingerprint.slice(0, -1)}${fingerprint.endsWith("0") ? "1" : "0"}`;
+ const request = page.context().request;
+ const headers = { Origin: new URL(baseUrl).origin };
+ const publicKey = { name: "recipient-public.pem", mimeType: "application/x-pem-file", buffer: await readFile(publicKeyPath) };
+ const response = await page.context().request.post(new URL("/api/files/encrypt", baseUrl).href, {
+ headers,
+ multipart: {
+ file: { name: "sample.txt", mimeType: "text/plain", buffer: inputBytes },
+ public_key: publicKey,
+ expected_recipient_fingerprint: different
+ }
+ });
+ assert.equal(response.status(), 400, "The backend must independently reject a mismatching recipient.");
+ assert.equal((await response.json()).error_code, "recipient_fingerprint_mismatch");
+
+ const reserved = await request.post(new URL("/api/jobs", baseUrl).href, {
+ headers, multipart: { mode: "encrypt", filename: "sample.txt", size: String(inputBytes.length) }
+ });
+ assert.equal(reserved.ok(), true);
+ const { job } = await reserved.json();
+ const jobUrl = (action) => new URL(`/api/jobs/${encodeURIComponent(job.id)}/${action}`, baseUrl).href;
+ try {
+ const uploaded = await request.put(jobUrl("upload"), {
+ headers: { ...headers, "Content-Type": "application/octet-stream" }, data: inputBytes
+ });
+ assert.equal(uploaded.ok(), true);
+ const started = await request.post(jobUrl("start"), {
+ headers, multipart: { key: publicKey, expected_recipient_fingerprint: different }
+ });
+ assert.equal(started.ok(), true);
+ let state = (await started.json()).job;
+ for (let attempt = 0; state.state === "running" && attempt < 50; attempt += 1) {
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ const status = await request.post(jobUrl("status"), { headers });
+ assert.equal(status.ok(), true);
+ state = (await status.json()).job;
+ }
+ assert.equal(state.state, "failed", "A wrong expected recipient must fail the native large-file job.");
+ assert.equal(state.error.code, "recipient_fingerprint_mismatch");
+ assert.equal(state.result, undefined, "A mismatching recipient must not publish an encrypted result.");
+ } finally {
+ await request.post(jobUrl("cancel"), { headers });
+ const cleared = await request.post(jobUrl("clear"), { headers });
+ assert.equal(cleared.ok(), true, "The recipient-check fixture must release its temporary files.");
+ }
+}
function assertReadyHealth(health) {
const requiredCapabilities = ["generate", "encrypt", "decrypt"];
@@ -47,7 +127,7 @@ async function decryptFromUi(page, encryptedPath, privateKeyPath, decryptedPath,
await page.getByText("File decrypted", { exact: true }).waitFor({ state: "visible" });
}
-async function runBatchRoundTrips(page, temporaryDirectory, publicKeyPath, privateKeyPath) {
+async function runBatchRoundTrips(page, temporaryDirectory, publicKeyPath, privateKeyPath, fingerprint) {
const inputs = [
{ filename: "batch-text.txt", bytes: Buffer.from("Batch browser round trip\nUTF-8: café, 日本語, 🔐\n") },
{ filename: "batch-binary.bin", bytes: Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256)) }
@@ -67,6 +147,7 @@ async function runBatchRoundTrips(page, temporaryDirectory, publicKeyPath, priva
);
await page.getByLabel("Recipient public key", { exact: true }).setInputFiles(publicKeyPath);
await page.getByText("Compatible public key", { exact: true }).waitFor({ state: "visible" });
+ await selectExpectedRecipient(page, fingerprint, "Encrypt batch");
await page.getByRole("button", { name: "Encrypt batch", exact: true }).click();
const results = page.getByRole("list", { name: "File encryption results" });
@@ -258,7 +339,7 @@ async function downloadLargeResult(page, destination) {
}
}
-async function runLargeFileRoundTrip(page, temporaryDirectory, publicKeyPath, privateKeyPath) {
+async function runLargeFileRoundTrip(page, temporaryDirectory, publicKeyPath, privateKeyPath, fingerprint) {
const plaintext = Buffer.alloc(2 * 1024 * 1024 + 17);
for (let index = 0; index < plaintext.length; index += 1) plaintext[index] = index % 251;
const inputPath = path.join(temporaryDirectory, "large-input.bin");
@@ -275,8 +356,10 @@ async function runLargeFileRoundTrip(page, temporaryDirectory, publicKeyPath, pr
await page.getByRole("heading", { name: "Large files", exact: true }).waitFor();
await page.getByLabel("File to encrypt", { exact: true }).setInputFiles(inputPath);
await page.getByLabel("Recipient public key", { exact: true }).setInputFiles(publicKeyPath);
+ await selectExpectedRecipient(page, fingerprint, "Encrypt large file");
await page.getByRole("button", { name: "Encrypt large file", exact: true }).click();
await page.getByRole("button", { name: "Download result", exact: true }).waitFor();
+ await capture(page, "custom-web-large-file-result.png");
assert.equal(responses.length, 0, "Large-file encryption must wait for an explicit download.");
await downloadLargeResult(page, encryptedPath);
await page.getByText("Download requested. The browser controls whether it finishes.", { exact: true }).waitFor();
@@ -323,16 +406,30 @@ async function run() {
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 inputPath = path.join(temporaryDirectory, "project-notes.txt");
+ const publicKeyPath = path.join(temporaryDirectory, "recipient-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 });
+ browser = await chromium.launch({ headless: true, executablePath: process.env.UI_BROWSER_EXECUTABLE });
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await context.newPage();
+ // Chromium may omit file-backed multipart bodies from request events. Observe
+ // only the public comparison field, forwarding every fetch unchanged.
+ await page.addInitScript(() => {
+ window.recipientChecks = [];
+ const originalFetch = window.fetch;
+ window.fetch = function (input, init) {
+ const pathname = new URL(input instanceof Request ? input.url : input, location.href).pathname;
+ if (init?.method === "POST" && init.body instanceof FormData &&
+ (pathname === "/api/files/encrypt" || /^\/api\/jobs\/[^/]+\/start$/.test(pathname))) {
+ window.recipientChecks.push({ pathname, expected: init.body.getAll("expected_recipient_fingerprint") });
+ }
+ return originalFetch.call(this, input, init);
+ };
+ });
await page.goto(baseUrl, { waitUntil: "networkidle" });
await page.getByRole("heading", { name: "Encrypt a file" }).waitFor();
@@ -343,25 +440,34 @@ async function run() {
return response.json();
});
assertReadyHealth(health);
+ assert.equal(health.supportsRecipientFingerprint, true, "The backend must enforce expected recipient fingerprints.");
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);
+ const generatedResponse = page.waitForResponse((response) =>
+ new URL(response.url()).pathname === "/api/keys/generate" && response.request().method() === "POST"
+ );
await page.getByRole("button", { name: "Generate key pair" }).click();
+ const fingerprint = (await (await generatedResponse).json()).publicKeyFingerprint;
+ assert.match(fingerprint, /^QE1-SHA3-256:[0-9a-f]{64}$/);
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 checkServerRecipientGuard(page, publicKeyPath, fingerprint);
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" });
+ await selectExpectedRecipient(page, fingerprint, "Encrypt file");
+ await capture(page, "custom-web-encrypt-workflow.png");
const encryptedDownload = page.waitForEvent("download");
await page.getByRole("button", { name: "Encrypt file" }).click();
await saveDownload(await encryptedDownload, encryptedPath);
@@ -370,13 +476,27 @@ async function run() {
await decryptFromUi(page, encryptedPath, privateKeyPath, decryptedPath);
assert.deepEqual(await readFile(decryptedPath), inputBytes, "The native browser round trip returned different bytes.");
- await runBatchRoundTrips(page, temporaryDirectory, publicKeyPath, privateKeyPath);
+ await runBatchRoundTrips(page, temporaryDirectory, publicKeyPath, privateKeyPath, fingerprint);
await runPasswordChange(page, temporaryDirectory, privateKeyPath, encryptedPath);
await runPublicKeyRecovery(page, temporaryDirectory, publicKeyPath, privateKeyPath);
await runFileVerification(page, temporaryDirectory, privateKeyPath, encryptedPath);
assert.equal(health.largeFiles?.available, true, "Large-file jobs must be available for the native browser checks.");
- await runLargeFileRoundTrip(page, temporaryDirectory, publicKeyPath, privateKeyPath);
- console.log("Native browser encryption/decryption, key password change, public-key recovery, verification, and large-file checks passed.");
+ await runLargeFileRoundTrip(page, temporaryDirectory, publicKeyPath, privateKeyPath, fingerprint);
+ const encryptionRequests = await page.evaluate(() => window.recipientChecks);
+ assert.deepEqual(encryptionRequests.filter(({ pathname }) => pathname === "/api/files/encrypt")
+ .map(({ expected }) => expected), [[fingerprint], [fingerprint], [fingerprint]],
+ "Single-file and both batch requests must carry exactly one expected recipient.");
+ assert.deepEqual(encryptionRequests.filter(({ pathname }) => pathname.endsWith("/start"))
+ .map(({ expected }) => expected), [[fingerprint], [], []],
+ "Only the encrypt-mode large-file start must carry the expected recipient.");
+ await page.getByRole("button", { name: "Inspect key", exact: true }).click();
+ await page.getByRole("heading", { name: "Inspect a key", exact: true }).waitFor();
+ await page.getByLabel("Key file", { exact: true }).setInputFiles(publicKeyPath);
+ await page.getByRole("region", { name: "Key inspection result" }).waitFor();
+ await page.getByRole("main").getByText("Technical details", { exact: true }).click();
+ await page.setViewportSize({ width: 390, height: 844 });
+ await capture(page, "custom-web-mobile-inspect.png");
+ console.log("Native browser recipient verification, encryption/decryption, key password change, public-key recovery, verification, and large-file checks passed.");
} finally {
try {
await browser?.close();
diff --git a/scripts/ui_smoke.mjs b/scripts/ui_smoke.mjs
index 9407100..5983809 100644
--- a/scripts/ui_smoke.mjs
+++ b/scripts/ui_smoke.mjs
@@ -157,7 +157,7 @@ let captureStaleInspectionRoute;
const staleInspectionRouteCaptured = new Promise((resolve) => {
captureStaleInspectionRoute = resolve;
});
-const browser = await chromium.launch({ headless: true });
+const browser = await chromium.launch({ headless: true, executablePath: process.env.UI_BROWSER_EXECUTABLE });
try {
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await context.newPage();
diff --git a/tests/test_agent_tools.py b/tests/test_agent_tools.py
index ec32f68..b204379 100644
--- a/tests/test_agent_tools.py
+++ b/tests/test_agent_tools.py
@@ -398,7 +398,6 @@ def test_encrypt_rejects_existing_output_without_overwrite(monkeypatch, tmp_path
output_path = tmp_path / "message.pqc"
output_path.write_bytes(b"existing")
- monkeypatch.setattr(core, "load_key_pem", lambda _pem: (b"public", cfg.HYBRID_KEM_ALG, "public"))
monkeypatch.setattr(tools, "_resolve_backend", lambda _operation, kem_alg=cfg.KEM_ALG: kem_alg)
_mock_stream_encryption(monkeypatch, lambda _data: b"encrypted")
@@ -762,7 +761,6 @@ def test_encrypt_mocked_flow_writes_encrypted_file(monkeypatch, tmp_path, capsys
monkeypatch.chdir(tmp_path)
(tmp_path / "message.txt").write_bytes(b"hello")
(tmp_path / "recipient.pem").write_text(_valid_public_pem(), encoding="utf-8")
- monkeypatch.setattr(core, "load_key_pem", lambda _pem: (b"public", cfg.HYBRID_KEM_ALG, "public"))
monkeypatch.setattr(tools, "_resolve_backend", lambda _operation, kem_alg=cfg.KEM_ALG: kem_alg)
_mock_stream_encryption(monkeypatch, lambda data: b"encrypted:" + data)
@@ -847,8 +845,11 @@ def test_stream_execution_failures_preserve_json_error_contract(
public = command == "encrypt"
(tmp_path / "key.pem").write_text(_valid_public_pem() if public else _valid_private_pem(), encoding="utf-8")
monkeypatch.setenv(tools.DEFAULT_PASSWORD_ENV, "correct horse battery staple")
+ key_bytes = bytes(range(cfg.X25519_KEY_BYTES)) + bytes(cfg.MLKEM768_PUBLIC_KEY_BYTES) if public else b"key"
monkeypatch.setattr(
- core, "load_key_pem", lambda *_args, **_kwargs: (b"key", cfg.HYBRID_KEM_ALG, "public" if public else "private")
+ core,
+ "load_key_pem",
+ lambda *_args, **_kwargs: (key_bytes, cfg.HYBRID_KEM_ALG, "public" if public else "private"),
)
monkeypatch.setattr(tools, "_resolve_backend", lambda *_args: cfg.KEM_ALG)
monkeypatch.setattr(tools, "_resolve_decryption_backends", lambda *_args: (cfg.KEM_ALG,))
@@ -1333,3 +1334,82 @@ def directory_sync_failed(*_args):
assert "published" in exc.value.message
assert output.read_bytes() == b"complete output"
assert not list(tmp_path.glob(".*.tmp"))
+
+
+@pytest.mark.parametrize("provide_expected", [False, True])
+def test_encrypt_reports_actual_recipient_fingerprint_with_matching_or_omitted_expectation(
+ monkeypatch, tmp_path, capsys, provide_expected
+):
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / "message.txt").write_bytes(b"hello")
+ (tmp_path / "recipient.pem").write_text(_valid_public_pem(), encoding="utf-8")
+ key_bytes = bytes(range(cfg.X25519_KEY_BYTES)) + bytes(cfg.MLKEM768_PUBLIC_KEY_BYTES)
+ fingerprint = core.get_public_key_fingerprint(key_bytes, cfg.HYBRID_KEM_ALG)
+ monkeypatch.setattr(tools, "_resolve_backend", lambda *_args: cfg.KEM_ALG)
+
+ def encrypt(source, sink, public_key, kem, **_kwargs):
+ assert public_key == key_bytes
+ assert kem == cfg.HYBRID_KEM_ALG
+ assert source.read() == b"hello"
+ sink.write(b"encrypted result")
+ return _stream_metadata(5, total_bytes=len(b"encrypted result"))
+
+ monkeypatch.setattr(tools.streaming, "encrypt_stream", encrypt)
+ arguments = ["encrypt", "--input", "message.txt", "--public-key", "recipient.pem", "--output", "message.pqc"]
+ if provide_expected:
+ arguments += ["--expected-recipient-fingerprint", fingerprint]
+ code, payload = _run_agent(arguments, capsys)
+ assert code == tools.EXIT_SUCCESS
+ assert payload["public_key_fingerprint"] == fingerprint
+ assert payload["bytes_written"] == len(b"encrypted result")
+ assert (tmp_path / "message.pqc").read_bytes() == b"encrypted result"
+
+
+@pytest.mark.parametrize("overwrite", [False, True])
+@pytest.mark.parametrize(
+ "expectation", ["different_recipient", "", "QE1-SHA3-256:abcd", "QE1-SHA3-256:" + "g" * 64, "非ASCII"]
+)
+def test_encrypt_rejects_recipient_expectation_before_crypto_or_output_creation(
+ monkeypatch, tmp_path, capsys, overwrite, expectation
+):
+ monkeypatch.chdir(tmp_path)
+ (tmp_path / "message.txt").write_bytes(b"private plaintext")
+ (tmp_path / "recipient.pem").write_text(_valid_public_pem(), encoding="utf-8")
+ output = tmp_path / "message.pqc"
+ if overwrite:
+ output.write_bytes(b"original destination")
+ mismatch = expectation == "different_recipient"
+ if mismatch:
+ other_key = bytes(reversed(range(cfg.X25519_KEY_BYTES))) + bytes(cfg.MLKEM768_PUBLIC_KEY_BYTES)
+ expectation = core.get_public_key_fingerprint(other_key, cfg.HYBRID_KEM_ALG)
+ before = sorted(tmp_path.iterdir())
+
+ def forbidden(*_args, **_kwargs):
+ pytest.fail("recipient verification must finish before backend work or output creation")
+
+ monkeypatch.setattr(tools, "_resolve_backend", forbidden)
+ monkeypatch.setattr(tools.streaming, "encrypt_stream", forbidden)
+ monkeypatch.setattr(tools, "_create_temporary_output", forbidden)
+ arguments = [
+ "encrypt",
+ "--input",
+ "message.txt",
+ "--public-key",
+ "recipient.pem",
+ "--output",
+ "message.pqc",
+ "--expected-recipient-fingerprint",
+ expectation,
+ ]
+ if overwrite:
+ arguments.append("--overwrite")
+ code, payload = _run_agent(arguments, capsys)
+ assert code == tools.EXIT_INVALID_INPUT
+ assert payload["operation"] == "encrypt"
+ assert payload["error_code"] == ("recipient_fingerprint_mismatch" if mismatch else "invalid_recipient_fingerprint")
+ assert "private plaintext" not in str(payload)
+ assert sorted(tmp_path.iterdir()) == before
+ if overwrite:
+ assert output.read_bytes() == b"original destination"
+ else:
+ assert not output.exists()
diff --git a/tests/test_api_app.py b/tests/test_api_app.py
index edd254f..9206a7f 100644
--- a/tests/test_api_app.py
+++ b/tests/test_api_app.py
@@ -97,11 +97,12 @@ def test_download_filename_suggestion_uses_existing_ui_helper():
def _multipart_form(
- files: list[tuple[str, str, bytes]], fields: dict[str, str] | None = None
+ files: list[tuple[str, str, bytes]], fields: dict[str, str] | list[tuple[str, str]] | None = None
) -> tuple[bytes, list[tuple[bytes, bytes]]]:
boundary = "test-boundary"
body = b""
- for name, value in (fields or {}).items():
+ field_items = fields.items() if isinstance(fields, dict) else fields or []
+ for name, value in field_items:
body += (f"--{boundary}\r\n" f'Content-Disposition: form-data; name="{name}"\r\n' "\r\n" f"{value}\r\n").encode(
"utf-8"
)
@@ -1614,6 +1615,70 @@ def test_encrypt_file_rejects_invalid_public_key(monkeypatch):
assert payload["error_code"] == "invalid_public_key"
+@pytest.mark.parametrize("failure", ["blank", "malformed", "duplicate", "file"])
+def test_encrypt_rejects_invalid_recipient_fingerprint_before_key_load(monkeypatch, tracked_uploads, failure):
+ fingerprint = "QE1-SHA3-256:" + "a" * 64
+ files = [("file", "file.bin", b"plaintext"), ("public_key", "public.pem", b"key")]
+ fields = [("expected_recipient_fingerprint", "" if failure == "blank" else fingerprint)]
+ if failure == "malformed":
+ fields = [("expected_recipient_fingerprint", fingerprint + "\n")]
+ elif failure == "duplicate":
+ fields.append(("expected_recipient_fingerprint", fingerprint))
+ elif failure == "file":
+ fields = []
+ files.append(("expected_recipient_fingerprint", "fingerprint.txt", fingerprint.encode()))
+ body, headers = _multipart_form(files, fields)
+ monkeypatch.setattr(
+ core, "load_key_pem", lambda *_args: pytest.fail("Invalid fingerprint must fail before key load")
+ )
+
+ status, response_headers, response_body = asyncio.run(
+ _call_app_raw("/api/files/encrypt", body=body, headers=_with_api_token(headers))
+ )
+
+ assert status == 400
+ assert json.loads(response_body)["error_code"] == "invalid_recipient_fingerprint"
+ assert fingerprint.encode() not in response_body
+ assert tracked_uploads and all(upload.closed for upload in tracked_uploads)
+ _assert_api_no_store(response_headers)
+
+
+@pytest.mark.parametrize("matched", [False, True])
+def test_encrypt_checks_fingerprint_of_loaded_key_before_encryption(monkeypatch, tracked_uploads, matched):
+ public = bytes(range(cfg.X25519_KEY_BYTES)) + bytes(cfg.MLKEM768_PUBLIC_KEY_BYTES)
+ expected = core.get_public_key_fingerprint(public, cfg.HYBRID_KEM_ALG)
+ if not matched:
+ expected = "QE1-SHA3-256:" + "0" * 64
+ body, headers = _multipart_form(
+ [("file", "plain.bin", b"plaintext"), ("public_key", "public.pem", b"key")],
+ {"expected_recipient_fingerprint": expected},
+ )
+ monkeypatch.setattr(core, "load_key_pem", lambda _pem: (public, cfg.HYBRID_KEM_ALG, "public"))
+ calls = []
+
+ def encrypt(data, key, algorithm):
+ calls.append((data, key, algorithm))
+ return b"ciphertext"
+
+ monkeypatch.setattr(core, "encrypt_file_pro", encrypt)
+ status, response_headers, response_body = asyncio.run(
+ _call_app_raw("/api/files/encrypt", body=body, headers=_with_api_token(headers))
+ )
+
+ if matched:
+ assert status == 200 and response_body == b"ciphertext"
+ assert calls == [(b"plaintext", public, cfg.HYBRID_KEM_ALG)]
+ else:
+ assert status == 400 and json.loads(response_body)["error_code"] == "recipient_fingerprint_mismatch"
+ assert not calls
+ assert tracked_uploads and all(upload.closed for upload in tracked_uploads)
+ _assert_api_no_store(response_headers)
+
+
+def test_recipient_fingerprint_capability_is_explicit():
+ assert api_app._health_payload()["supportsRecipientFingerprint"] is True
+
+
@pytest.mark.parametrize("legacy_kem", [cfg.KEM_ALG, cfg.LEGACY_HYBRID_KEM_ALG])
def test_encrypt_file_rejects_legacy_public_key(monkeypatch, legacy_kem):
body, headers = _file_workflow_body()
diff --git a/tests/test_api_jobs_api.py b/tests/test_api_jobs_api.py
index a35ca0a..8d17a4f 100644
--- a/tests/test_api_jobs_api.py
+++ b/tests/test_api_jobs_api.py
@@ -14,14 +14,18 @@ def _form(fields):
return urlencode(fields).encode(), [(b"content-type", b"application/x-www-form-urlencoded")]
-def _key_form(password=""):
+def _key_form(password="", fingerprints=(), fingerprint_file=False):
body = (
b'--job-boundary\r\nContent-Disposition: form-data; name="key"; filename="key.pem"\r\n'
b"Content-Type: application/octet-stream\r\n\r\nsynthetic key\r\n"
- b'--job-boundary\r\nContent-Disposition: form-data; name="password"\r\n\r\n'
- + password.encode()
- + b"\r\n--job-boundary--\r\n"
+ b'--job-boundary\r\nContent-Disposition: form-data; name="password"\r\n\r\n' + password.encode() + b"\r\n"
)
+ for fingerprint in fingerprints:
+ disposition = 'Content-Disposition: form-data; name="expected_recipient_fingerprint"'
+ if fingerprint_file:
+ disposition += '; filename="fingerprint.txt"'
+ body += b"--job-boundary\r\n" + disposition.encode() + b"\r\n\r\n" + fingerprint.encode() + b"\r\n"
+ body += b"--job-boundary--\r\n"
return body, [(b"content-type", b"multipart/form-data; boundary=job-boundary")]
@@ -100,11 +104,98 @@ async def _reserve(app, mode="encrypt", size=7, filename="payload.bin"):
return json.loads(data)["job"]
+@pytest.mark.parametrize("failure", ["blank", "malformed", "duplicate", "file", "decrypt", "verify"])
+def test_job_start_rejects_invalid_recipient_fingerprint_fields_without_worker(monkeypatch, failure):
+ fingerprint = "QE1-SHA3-256:" + "a" * 64
+ values = [fingerprint]
+ if failure == "blank":
+ values = [""]
+ elif failure == "malformed":
+ values = [fingerprint + "\n"]
+ elif failure == "duplicate":
+ values *= 2
+ monkeypatch.setattr(JobStore, "start", lambda *_args: pytest.fail("Invalid fingerprint must not start a job"))
+ closed_uploads = []
+ original_close = api_app.UploadFile.close
+
+ async def close(upload):
+ await original_close(upload)
+ closed_uploads.append(upload.file.closed)
+
+ monkeypatch.setattr(api_app.UploadFile, "close", close)
+
+ async def scenario():
+ app = api_app.create_app()
+ store = app.app.state.jobs
+ try:
+ mode = failure if failure in {"decrypt", "verify"} else "encrypt"
+ reserved = await _reserve(app, mode=mode)
+ path = f'/api/jobs/{reserved["id"]}'
+ status, _, _, _ = await _request(
+ app,
+ path + "/upload",
+ method="PUT",
+ body=b"payload",
+ headers=[(b"content-type", b"application/octet-stream")],
+ )
+ assert status == 200
+ body, headers = _key_form(fingerprints=values, fingerprint_file=failure == "file")
+ status, response_headers, data, _ = await _request(app, path + "/start", body=body, headers=headers)
+ assert status == 400 and json.loads(data)["error_code"] == "invalid_recipient_fingerprint"
+ assert response_headers[b"cache-control"] == b"no-store"
+ assert fingerprint.encode() not in data
+ assert store.job.state == "ready" and store.job.task is None and store.job.output is None
+ assert closed_uploads and all(closed_uploads)
+ status, _, _, _ = await _request(app, path + "/clear")
+ assert status == 200 and store.job is None
+ lease = store.worker.acquire()
+ assert lease is not None
+ lease.close()
+ finally:
+ await store.close()
+
+ asyncio.run(scenario())
+
+
+def test_job_start_passes_supplied_recipient_fingerprint_to_worker(monkeypatch):
+ fingerprint = "QE1-SHA3-256:" + "a" * 64
+ received = []
+
+ def process(self, job, pem, password, filename, expected_recipient_fingerprint):
+ received.append(expected_recipient_fingerprint)
+
+ monkeypatch.setattr(JobStore, "_process", process)
+
+ async def scenario():
+ app = api_app.create_app()
+ store = app.app.state.jobs
+ try:
+ reserved = await _reserve(app)
+ path = f'/api/jobs/{reserved["id"]}'
+ await _request(
+ app,
+ path + "/upload",
+ method="PUT",
+ body=b"payload",
+ headers=[(b"content-type", b"application/octet-stream")],
+ )
+ body, headers = _key_form(fingerprints=[fingerprint])
+ status, _, _, _ = await _request(app, path + "/start", body=body, headers=headers)
+ assert status == 200
+ await store.job.task
+ assert received == [fingerprint]
+ finally:
+ await store.close()
+
+ asyncio.run(scenario())
+
+
@pytest.mark.parametrize("mode", ["encrypt", "decrypt", "verify"])
def test_jobs_upload_start_status_download_and_clear(monkeypatch, mode):
event_loop_thread = threading.get_ident()
- def process(self, job, pem, password, output_filename):
+ def process(self, job, pem, password, output_filename, expected_recipient_fingerprint=None):
+ assert expected_recipient_fingerprint is None
assert threading.get_ident() != event_loop_thread
assert pem == "synthetic key"
assert password == ("" if mode == "encrypt" else "synthetic password")
diff --git a/tests/test_api_jobs_crypto.py b/tests/test_api_jobs_crypto.py
index 475a0f4..0f2fb33 100644
--- a/tests/test_api_jobs_crypto.py
+++ b/tests/test_api_jobs_crypto.py
@@ -188,6 +188,60 @@ def test_jobs_run_real_stream_crypto_with_authenticated_results(fake_oqs, keys,
asyncio.run(_successful_operation(mode, keys[0]))
+@pytest.mark.parametrize("matched", [False, True])
+def test_job_recipient_fingerprint_checked_before_output_creation(
+ monkeypatch, fake_oqs, keys, temporary_files, matched
+):
+ material, other = keys
+ expected = core.get_public_key_fingerprint((material if matched else other).public, cfg.HYBRID_KEM_ALG)
+ if not matched:
+ monkeypatch.setattr(
+ jobs.stream, "encrypt_stream", lambda *_args, **_kwargs: pytest.fail("Mismatch must not encrypt")
+ )
+
+ async def scenario():
+ store = jobs.JobStore(CryptoWorker())
+ try:
+ job = await _ready(store, "encrypt", PLAINTEXT)
+ store.start(job, material.public_pem, "", "result.pqc", expected)
+ assert job.task is not None
+ await asyncio.wait_for(job.task, 10)
+ if matched:
+ assert job.state == "complete" and job.output is not None
+ assert core.decrypt_file_pro(job.output.read(), material.private)[0] == PLAINTEXT
+ else:
+ assert job.snapshot()["error"] == {
+ "code": "recipient_fingerprint_mismatch",
+ "message": "The public key does not match the expected recipient fingerprint.",
+ }
+ assert job.state == "failed" and job.output is None and job.result is None
+ assert len(temporary_files) == 1 and temporary_files[0].closed
+ lease = store.worker.acquire()
+ assert lease is not None
+ lease.close()
+ finally:
+ await store.close()
+
+ asyncio.run(scenario())
+
+
+@pytest.mark.parametrize(
+ "mode,expected", [("encrypt", ""), ("decrypt", "QE1-SHA3-256:" + "a" * 64), ("verify", "QE1-SHA3-256:" + "a" * 64)]
+)
+def test_job_recipient_fingerprint_invalid_start_does_not_launch_worker(temporary_files, mode, expected):
+ async def scenario():
+ store = jobs.JobStore(CryptoWorker())
+ try:
+ job = await _ready(store, mode, b"input")
+ with pytest.raises(core.InvalidRecipientFingerprintError):
+ store.start(job, "unused key", "", "result", expected)
+ assert job.state == "ready" and job.task is None and job.output is None
+ finally:
+ await store.close()
+
+ asyncio.run(scenario())
+
+
@pytest.mark.parametrize("mode", ["decrypt", "verify"])
@pytest.mark.parametrize("failure", ["password", "wrong_key", "tampered_payload"])
def test_job_authentication_failures_have_no_plaintext_result(fake_oqs, keys, temporary_files, mode, failure):
diff --git a/tests/test_crypto_core.py b/tests/test_crypto_core.py
index 9594453..eb66149 100644
--- a/tests/test_crypto_core.py
+++ b/tests/test_crypto_core.py
@@ -134,6 +134,48 @@ def _fake_hybrid_keys() -> tuple[bytes, bytes, bytes, bytes]:
)
+def test_recipient_fingerprint_accepts_only_the_loaded_public_key(monkeypatch):
+ public, _private, _mlkem_public, _mlkem_private = _fake_hybrid_keys()
+ fingerprint = core.get_public_key_fingerprint(public, cfg.HYBRID_KEM_ALG)
+ monkeypatch.setattr(
+ core, "_require_oqs", lambda: pytest.fail("Fingerprint verification requires no native backend")
+ )
+ assert core.verify_recipient_fingerprint(public, cfg.HYBRID_KEM_ALG) == fingerprint
+ assert core.verify_recipient_fingerprint(public, cfg.HYBRID_KEM_ALG, fingerprint) == fingerprint
+ different_public, *_rest = _fake_hybrid_keys()
+ with pytest.raises(core.RecipientFingerprintMismatchError):
+ core.verify_recipient_fingerprint(different_public, cfg.HYBRID_KEM_ALG, fingerprint)
+ with pytest.raises(core.RecipientFingerprintMismatchError):
+ core.verify_recipient_fingerprint(public, cfg.LEGACY_HYBRID_KEM_ALG, fingerprint)
+
+
+@pytest.mark.parametrize(
+ "expected",
+ [
+ "",
+ " ",
+ "a" * 64,
+ "QE1-SHA3-256:" + "A" * 64,
+ "QE1-SHA3-256:" + "a" * 63,
+ "QE1-SHA3-256:" + "a" * 65,
+ "QE1-SHA3-256:" + "a" * 64 + "\n",
+ " QE1-SHA3-256:" + "a" * 64,
+ "QE1-SHA3-256:" + "é" * 64,
+ 42,
+ b"fingerprint",
+ ],
+)
+def test_recipient_fingerprint_rejects_noncanonical_expected_values(expected):
+ public, *_rest = _fake_hybrid_keys()
+ with pytest.raises(core.InvalidRecipientFingerprintError):
+ core.verify_recipient_fingerprint(public, cfg.HYBRID_KEM_ALG, expected)
+
+
+def test_recipient_fingerprint_still_validates_key_material():
+ with pytest.raises(core.InvalidKeyFormatError):
+ core.verify_recipient_fingerprint(b"invalid key", cfg.HYBRID_KEM_ALG)
+
+
@pytest.fixture
def fake_oqs_backend(monkeypatch):
"""Provide deterministic KEM behavior without requiring a native liboqs installation."""
diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts
new file mode 100644
index 0000000..aab4a51
--- /dev/null
+++ b/web/src/api/client.test.ts
@@ -0,0 +1,104 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { READY_HEALTH } from "../test/fixtures";
+
+const fingerprint = `QE1-SHA3-256:${"a".repeat(64)}`;
+const json = (payload: unknown, status = 200) => new Response(JSON.stringify(payload), { status });
+const file = new File(["file"], "file.txt");
+const key = new File(["PEM"], "public.pem");
+const unsupportedError = {
+ status: 409, code: "recipient_fingerprint_unsupported",
+ message: "Restart an updated local service to enforce the expected recipient fingerprint."
+};
+beforeEach(() => vi.resetModules());
+afterEach(() => vi.unstubAllGlobals());
+
+describe("recipient fingerprint encryption requests", () => {
+ it.each([undefined, false])("does not send protected encryption to a service without fresh support (%s)", async (supportsRecipientFingerprint) => {
+ const fetch = vi.fn().mockImplementation((url: string) => Promise.resolve(url === "/api/health"
+ ? json({ ...READY_HEALTH, supportsRecipientFingerprint }) : new Response("ciphertext")));
+ vi.stubGlobal("fetch", fetch);
+ const { encryptFile } = await import("./client");
+ await expect(encryptFile(file, key, "file.pqc", undefined, fingerprint)).rejects.toMatchObject(unsupportedError);
+ expect(fetch.mock.calls.map(([url]) => url)).toEqual(["/api/health"]);
+ });
+
+ it("rechecks support for a protected request after the authentication cookie was already bootstrapped", async () => {
+ const fetch = vi.fn().mockResolvedValueOnce(json(READY_HEALTH))
+ .mockResolvedValueOnce(new Response("unprotected ciphertext"))
+ .mockResolvedValueOnce(json({ ...READY_HEALTH, supportsRecipientFingerprint: false }));
+ vi.stubGlobal("fetch", fetch);
+ const { encryptFile } = await import("./client");
+ await encryptFile(file, key, "file.pqc");
+ await expect(encryptFile(file, key, "file.pqc", undefined, fingerprint)).rejects.toMatchObject(unsupportedError);
+ expect(fetch.mock.calls.map(([url]) => url)).toEqual(["/api/health", "/api/files/encrypt", "/api/health"]);
+ });
+
+ it("does not retry protected encryption when a restarted service loses support", async () => {
+ const fetch = vi.fn().mockResolvedValueOnce(json(READY_HEALTH))
+ .mockResolvedValueOnce(json({ error_code: "missing_api_token" }, 403))
+ .mockResolvedValueOnce(json({ ...READY_HEALTH, supportsRecipientFingerprint: undefined }))
+ .mockResolvedValueOnce(new Response("unsafe ciphertext"));
+ vi.stubGlobal("fetch", fetch);
+ const { encryptFile } = await import("./client");
+ await expect(encryptFile(file, key, "file.pqc", undefined, fingerprint)).rejects.toMatchObject(unsupportedError);
+ expect(fetch.mock.calls.map(([url]) => url)).toEqual(["/api/health", "/api/files/encrypt", "/api/health"]);
+ });
+
+ it("retries authentication once only when the refreshed service still enforces fingerprints", async () => {
+ const fetch = vi.fn().mockResolvedValueOnce(json(READY_HEALTH))
+ .mockResolvedValueOnce(json({ error_code: "missing_api_token" }, 403))
+ .mockResolvedValueOnce(json(READY_HEALTH)).mockResolvedValueOnce(new Response("ciphertext"));
+ vi.stubGlobal("fetch", fetch);
+ const { encryptFile } = await import("./client");
+ const signal = new AbortController().signal;
+ await encryptFile(file, key, "file.pqc", signal, fingerprint);
+ expect(fetch.mock.calls.map(([url]) => url)).toEqual(["/api/health", "/api/files/encrypt", "/api/health", "/api/files/encrypt"]);
+ const posts = fetch.mock.calls.filter(([url]) => url === "/api/files/encrypt");
+ for (const [, request] of posts) {
+ expect(request.signal).toBe(signal);
+ expect(request.body.get("expected_recipient_fingerprint")).toBe(fingerprint);
+ }
+ });
+
+ it.each([false, true])("does not post after cancellation during a support refresh (retry=%s)", async (retry) => {
+ let resolveHealth!: (response: Response) => void;
+ const health = new Promise((resolve) => { resolveHealth = resolve; });
+ const fetch = vi.fn();
+ if (retry) fetch.mockResolvedValueOnce(json(READY_HEALTH)).mockResolvedValueOnce(json({ error_code: "missing_api_token" }, 403));
+ fetch.mockReturnValueOnce(health).mockResolvedValue(new Response("must not encrypt"));
+ vi.stubGlobal("fetch", fetch);
+ const { encryptFile } = await import("./client");
+ const controller = new AbortController();
+ const pending = encryptFile(file, key, "file.pqc", controller.signal, fingerprint);
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(retry ? 3 : 1));
+ controller.abort();
+ resolveHealth(json(READY_HEALTH));
+ await expect(pending).rejects.toMatchObject({ name: "AbortError" });
+ expect(fetch.mock.calls.filter(([url]) => url === "/api/files/encrypt")).toHaveLength(retry ? 1 : 0);
+ });
+
+ it.each([undefined, fingerprint, ""])("preserves an explicitly supplied expectation and omits only undefined (%s)", async (expected) => {
+ const fetch = vi.fn().mockImplementation((url: string) => Promise.resolve(url === "/api/health"
+ ? new Response(JSON.stringify(READY_HEALTH)) : new Response("ciphertext")));
+ vi.stubGlobal("fetch", fetch);
+ const { encryptFile } = await import("./client");
+ const signal = new AbortController().signal;
+ await encryptFile(new File(["file"], "file.txt"), new File(["PEM"], "public.pem"), "file.pqc", signal, expected);
+ const [, request] = fetch.mock.calls.find(([url]) => url === "/api/files/encrypt")!;
+ expect(request.method).toBe("POST");
+ expect(request.signal).toBe(signal);
+ expect(request.body.get("expected_recipient_fingerprint")).toBe(expected ?? null);
+ expect(request.body.get("output_filename")).toBe("file.pqc");
+ });
+
+ it.each(["invalid_recipient_fingerprint", "recipient_fingerprint_mismatch"])("does not retry a rejected fingerprint (%s)", async (code) => {
+ const fetch = vi.fn().mockImplementation((url: string) => Promise.resolve(url === "/api/health"
+ ? new Response(JSON.stringify(READY_HEALTH))
+ : new Response(JSON.stringify({ error_code: code, message: "Check the expected fingerprint." }), { status: 400 })));
+ vi.stubGlobal("fetch", fetch);
+ const { encryptFile } = await import("./client");
+ await expect(encryptFile(new File(["a"], "a"), new File(["PEM"], "key.pem"), "a.pqc", undefined, fingerprint))
+ .rejects.toMatchObject({ status: 400, code, message: "Check the expected fingerprint." });
+ expect(fetch.mock.calls.filter(([url]) => url === "/api/files/encrypt")).toHaveLength(1);
+ });
+});
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index 2be859a..edb70d6 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -20,6 +20,13 @@ export class ApiError extends Error {
}
}
+export function assertRecipientFingerprintSupport(health: Health): void {
+ if (health?.supportsRecipientFingerprint !== true) {
+ throw new ApiError(409, "recipient_fingerprint_unsupported",
+ "Restart an updated local service to enforce the expected recipient fingerprint.");
+ }
+}
+
async function rejectedApiToken(response: Response): Promise {
if (response.status !== 403) return false;
try {
@@ -39,12 +46,23 @@ async function ensureHealth(): Promise {
}
}
-async function fetchStateChanging(input: RequestInfo | URL, init: RequestInit): Promise {
- await ensureHealth();
+async function fetchStateChanging(input: RequestInfo | URL, init: RequestInit, requireRecipientFingerprint = false): Promise {
+ if (requireRecipientFingerprint) {
+ init.signal?.throwIfAborted();
+ const health = await fetchHealth();
+ init.signal?.throwIfAborted();
+ assertRecipientFingerprintSupport(health);
+ healthLoaded = true;
+ } else await ensureHealth();
let response = await fetch(input, init);
if (await rejectedApiToken(response)) {
// The per-process token rotates on server restart; renew the auth cookie and retry once.
- await fetchHealth();
+ if (requireRecipientFingerprint) init.signal?.throwIfAborted();
+ const health = await fetchHealth();
+ if (requireRecipientFingerprint) {
+ init.signal?.throwIfAborted();
+ assertRecipientFingerprintSupport(health);
+ }
response = await fetch(input, init);
}
return response;
@@ -160,13 +178,15 @@ export async function encryptFile(
file: File,
publicKey: File,
outputFilename: string,
- signal?: AbortSignal
+ signal?: AbortSignal,
+ expectedRecipientFingerprint?: string
): Promise {
const form = new FormData();
form.append("file", file);
form.append("public_key", publicKey);
form.append("output_filename", outputFilename);
- const response = await fetchStateChanging("/api/files/encrypt", { method: "POST", body: form, signal });
+ if (expectedRecipientFingerprint !== undefined) form.append("expected_recipient_fingerprint", expectedRecipientFingerprint);
+ const response = await fetchStateChanging("/api/files/encrypt", { method: "POST", body: form, signal }, expectedRecipientFingerprint !== undefined);
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
index 6ea7da3..d38d77c 100644
--- a/web/src/api/contracts.ts
+++ b/web/src/api/contracts.ts
@@ -10,6 +10,7 @@ export type Health = {
supportsKeyPasswordChange?: boolean;
supportsPublicKeyRecovery?: boolean;
supportsFileVerification?: boolean;
+ supportsRecipientFingerprint?: boolean;
largeFiles?: {
available: boolean;
maxPlaintextBytes: number;
@@ -140,7 +141,8 @@ export type EncryptFileOperation = (
file: File,
publicKey: File,
outputFilename: string,
- signal?: AbortSignal
+ signal?: AbortSignal,
+ expectedRecipientFingerprint?: string
) => Promise;
export type DecryptFileOperation = (
file: File,
diff --git a/web/src/api/largeFiles.test.ts b/web/src/api/largeFiles.test.ts
index b9ddf79..fe59e93 100644
--- a/web/src/api/largeFiles.test.ts
+++ b/web/src/api/largeFiles.test.ts
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fetchHealth } from "./client";
import { largeFileOperations, type LargeFileJob } from "./largeFiles";
+import { READY_HEALTH } from "../test/fixtures";
vi.mock("./client", async (actual) => ({ ...await actual(), fetchHealth: vi.fn() }));
const job: LargeFileJob = { id: "opaque-id", mode: "encrypt", state: "awaiting_upload", phase: "upload", processedBytes: 0, totalBytes: 3, expiresAt: "2099-01-01T00:00:00Z" };
@@ -22,10 +23,51 @@ class UploadRequest {
abort = vi.fn(() => this.onabort?.());
}
-beforeEach(() => { vi.mocked(fetchHealth).mockResolvedValue({} as Awaited>); });
+beforeEach(() => { vi.mocked(fetchHealth).mockResolvedValue(READY_HEALTH); });
afterEach(() => { vi.unstubAllGlobals(); });
describe("large file client", () => {
+ it.each([undefined, false])("refuses protected start when the refreshed service does not enforce fingerprints (%s)", async (supportsRecipientFingerprint) => {
+ vi.mocked(fetchHealth).mockResolvedValue({ ...READY_HEALTH, supportsRecipientFingerprint });
+ const fetch = vi.fn().mockResolvedValue(json({ ok: true, job }));
+ vi.stubGlobal("fetch", fetch);
+ await expect(largeFileOperations.start(job.id, new File(["PEM"], "public.pem"), "", new AbortController().signal, `QE1-SHA3-256:${"a".repeat(64)}`))
+ .rejects.toMatchObject({ status: 409, code: "recipient_fingerprint_unsupported", message: "Restart an updated local service to enforce the expected recipient fingerprint." });
+ expect(fetchHealth).toHaveBeenCalledTimes(1);
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it("does not start after cancellation during the fresh support check", async () => {
+ let resolveHealth!: (health: typeof READY_HEALTH) => void;
+ vi.mocked(fetchHealth).mockReturnValue(new Promise((resolve) => { resolveHealth = resolve; }));
+ const fetch = vi.fn().mockResolvedValue(json({ ok: true, job }));
+ vi.stubGlobal("fetch", fetch);
+ const controller = new AbortController();
+ const pending = largeFileOperations.start(job.id, new File(["PEM"], "public.pem"), "", controller.signal, `QE1-SHA3-256:${"a".repeat(64)}`);
+ controller.abort();
+ resolveHealth(READY_HEALTH);
+ await expect(pending).rejects.toMatchObject({ name: "AbortError" });
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it.each([undefined, `QE1-SHA3-256:${"a".repeat(64)}`, ""])("sends a supplied recipient expectation only with start (%s)", async (expected) => {
+ const fetch = vi.fn().mockResolvedValue(json({ ok: true, job }));
+ vi.stubGlobal("fetch", fetch);
+ await largeFileOperations.start(job.id, new File(["PEM"], "public.pem"), "", new AbortController().signal, expected);
+ const [url, request] = fetch.mock.calls[0];
+ expect(url).toBe("/api/jobs/opaque-id/start");
+ expect(request.body.get("expected_recipient_fingerprint")).toBe(expected ?? null);
+ expect(request.body.get("password")).toBe("");
+ });
+
+ it("does not replay a rejected recipient check at start", async () => {
+ const fetch = vi.fn().mockResolvedValue(json({ error_code: "recipient_fingerprint_mismatch", message: "Check the expected fingerprint." }, 400));
+ vi.stubGlobal("fetch", fetch);
+ await expect(largeFileOperations.start(job.id, new File(["PEM"], "public.pem"), "", new AbortController().signal, `QE1-SHA3-256:${"a".repeat(64)}`))
+ .rejects.toMatchObject({ status: 400, code: "recipient_fingerprint_mismatch" });
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
it("bootstraps the cookie and reserves using metadata only", async () => {
const fetch = vi.fn().mockResolvedValue(json({ ok: true, job }));
vi.stubGlobal("fetch", fetch);
diff --git a/web/src/api/largeFiles.ts b/web/src/api/largeFiles.ts
index d7160df..b4255a7 100644
--- a/web/src/api/largeFiles.ts
+++ b/web/src/api/largeFiles.ts
@@ -1,4 +1,4 @@
-import { ApiError, fetchHealth } from "./client";
+import { ApiError, assertRecipientFingerprintSupport, fetchHealth } from "./client";
import type { FileVerification } from "./contracts";
export type LargeFileMode = "encrypt" | "decrypt" | "verify";
@@ -18,7 +18,7 @@ export type LargeFileJob = {
export type LargeFileOperations = {
create: (mode: LargeFileMode, file: File, signal: AbortSignal) => Promise;
upload: (id: string, file: File, progress: (loaded: number, total: number) => void, signal: AbortSignal) => Promise;
- start: (id: string, key: File, password: string, signal: AbortSignal) => Promise;
+ start: (id: string, key: File, password: string, signal: AbortSignal, expectedRecipientFingerprint?: string) => Promise;
status: (id: string, signal?: AbortSignal) => Promise;
cancel: (id: string, signal?: AbortSignal) => Promise;
clear: (id: string, signal?: AbortSignal) => Promise;
@@ -98,10 +98,17 @@ export const largeFileOperations: LargeFileOperations = {
try { xhr.send(file); } catch (error) { finish(); reject(error); }
});
},
- async start(id, key, password, signal) {
+ async start(id, key, password, signal, expectedRecipientFingerprint) {
+ if (expectedRecipientFingerprint !== undefined) {
+ signal.throwIfAborted();
+ const health = await fetchHealth();
+ signal.throwIfAborted();
+ assertRecipientFingerprintSupport(health);
+ }
const form = new FormData();
form.append("key", key);
form.append("password", password);
+ if (expectedRecipientFingerprint !== undefined) form.append("expected_recipient_fingerprint", expectedRecipientFingerprint);
const response = await fetch(jobUrl(id, "start"), { method: "POST", body: form, credentials: "same-origin", signal });
return jobPayload(await parseResponse(response) as { job: LargeFileJob; ok: boolean });
},
diff --git a/web/src/components/RecipientFingerprintField.tsx b/web/src/components/RecipientFingerprintField.tsx
new file mode 100644
index 0000000..ad9ca62
--- /dev/null
+++ b/web/src/components/RecipientFingerprintField.tsx
@@ -0,0 +1,29 @@
+import { recipientFingerprintError } from "../lib/recipientFingerprint";
+
+type RecipientFingerprintFieldProps = {
+ id: string;
+ value: string;
+ actual: string | null | undefined;
+ supported: boolean;
+ disabled: boolean;
+ onChange: (value: string) => void;
+};
+
+export function RecipientFingerprintField({ id, value, actual, supported, disabled, onChange }: RecipientFingerprintFieldProps) {
+ const error = recipientFingerprintError(value, actual, supported);
+ const status = error || (value.trim()
+ ? actual ? "Expected fingerprint matches the selected public key." : "Waiting for the current recipient key inspection."
+ : null);
+ return (
+
+
Expected recipient fingerprint (optional)
+
+ );
+}
diff --git a/web/src/features/encrypt/BatchEncryptWorkflow.test.tsx b/web/src/features/encrypt/BatchEncryptWorkflow.test.tsx
index 716a0a9..7a4d0e9 100644
--- a/web/src/features/encrypt/BatchEncryptWorkflow.test.tsx
+++ b/web/src/features/encrypt/BatchEncryptWorkflow.test.tsx
@@ -32,6 +32,48 @@ async function prepareBatch(user: ReturnType, files = [n
}
describe("BatchEncryptWorkflow", () => {
+ it.each([
+ ["not a fingerprint", true],
+ [`QE1-SHA3-256:${"b".repeat(64)}`, true],
+ [FINGERPRINT, undefined]
+ ])("blocks an invalid, mismatched, or unsupported batch expectation (%s)", async (expected, supportsRecipientFingerprint) => {
+ const user = userEvent.setup();
+ const encrypt = vi.fn();
+ render( );
+ await prepareBatch(user);
+ const field = screen.getByLabelText("Expected recipient fingerprint (optional)");
+ await user.type(field, expected as string);
+ expect(field).toHaveAttribute("aria-invalid", "true");
+ expect(screen.getByRole("button", { name: "Encrypt batch" })).toBeDisabled();
+ fireEvent.submit(field.closest("form")!);
+ expect(encrypt).not.toHaveBeenCalled();
+ });
+
+ it("captures one expectation for every file and clears it only when the batch is cleared", async () => {
+ const user = userEvent.setup();
+ const pending = deferred();
+ const encrypt = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValueOnce(ciphertext("b.txt.pqc"));
+ render( );
+ const files = [new File(["a"], "a.txt"), new File(["b"], "b.txt")];
+ await prepareBatch(user, files);
+ const field = screen.getByLabelText("Expected recipient fingerprint (optional)");
+ await user.type(field, ` ${FINGERPRINT} `);
+ await user.click(screen.getByRole("button", { name: "Encrypt batch" }));
+ expect(field).toBeDisabled();
+ fireEvent.change(field, { target: { value: `QE1-SHA3-256:${"b".repeat(64)}` } });
+ expect(field).toHaveValue(` ${FINGERPRINT} `);
+ await act(async () => pending.resolve(ciphertext("a.txt.pqc")));
+ await screen.findByRole("button", { name: "Download b.txt.pqc" });
+ for (const [index, file] of files.entries()) {
+ expect(encrypt).toHaveBeenNthCalledWith(index + 1, file, expect.any(File), `${file.name}.pqc`, expect.any(AbortSignal), FINGERPRINT);
+ }
+ expect(encrypt).toHaveBeenCalledTimes(2);
+ expect(field).toBeDisabled();
+ await user.click(screen.getByRole("button", { name: "Clear batch" }));
+ expect(field).toHaveValue("");
+ expect(field).toBeEnabled();
+ });
+
it("adds selected and dropped files, removes files, and allows selecting them again", async () => {
const user = userEvent.setup();
render( );
diff --git a/web/src/features/encrypt/BatchEncryptWorkflow.tsx b/web/src/features/encrypt/BatchEncryptWorkflow.tsx
index a2b6033..048bb65 100644
--- a/web/src/features/encrypt/BatchEncryptWorkflow.tsx
+++ b/web/src/features/encrypt/BatchEncryptWorkflow.tsx
@@ -9,10 +9,12 @@ import {
import { ActionButton } from "../../components/ActionButton";
import { FilePicker } from "../../components/FilePicker";
import { Notice } from "../../components/Notice";
+import { RecipientFingerprintField } from "../../components/RecipientFingerprintField";
import { WorkflowLayout } from "../../components/WorkflowLayout";
import { useKeyInspection } from "../../hooks/useKeyInspection";
import { downloadBlob } from "../../lib/download";
import { formatBytes } from "../../lib/format";
+import { isPublicKeyFingerprint, recipientFingerprintError } from "../../lib/recipientFingerprint";
import { deriveWorkflowPhase } from "../../lib/workflow";
import { MAX_BATCH_FILES, useBatchEncryption, validateBatchFiles } from "./useBatchEncryption";
@@ -24,7 +26,6 @@ export type BatchEncryptWorkflowProps = {
onPendingResultsChange?: (pending: boolean) => void;
};
-const FINGERPRINT_PREFIX = "QE1-SHA3-256:";
const ITEM_STATUS_LABELS = {
queued: "Queued",
encrypting: "Encrypting",
@@ -33,11 +34,6 @@ const ITEM_STATUS_LABELS = {
cancelled: "Cancelled"
};
-function validFingerprint(value: unknown): value is string {
- return typeof value === "string" && value.length === FINGERPRINT_PREFIX.length + 64 &&
- /^QE1-SHA3-256:[0-9a-f]{64}$/.test(value);
-}
-
export function BatchEncryptWorkflow({
health,
inspect,
@@ -47,6 +43,7 @@ export function BatchEncryptWorkflow({
}: BatchEncryptWorkflowProps) {
const [files, setFiles] = useState([]);
const [publicKey, setPublicKey] = useState(null);
+ const [expectedFingerprint, setExpectedFingerprint] = useState("");
const [selectionError, setSelectionError] = useState(null);
const [downloaded, setDownloaded] = useState>(() => new Set());
const [downloadErrors, setDownloadErrors] = useState>({});
@@ -64,9 +61,10 @@ export function BatchEncryptWorkflow({
: null;
const compatibleKey = Boolean(
inspection?.ok && inspection.keyInfo.key_type === "public" &&
- inspection.keyInfo.kem === health.kem && validFingerprint(inspection.keyInfo.public_key_fingerprint)
+ inspection.keyInfo.kem === health.kem && isPublicKeyFingerprint(inspection.keyInfo.public_key_fingerprint)
);
const fingerprint = compatibleKey ? inspection?.keyInfo.public_key_fingerprint : null;
+ const fingerprintError = recipientFingerprintError(expectedFingerprint, fingerprint, health.supportsRecipientFingerprint === true);
const validationError = validateBatchFiles(files, health.maxFileBytes);
function getReadinessReason(): string | null {
if (!capability.available) return capability.reason;
@@ -79,10 +77,10 @@ export function BatchEncryptWorkflow({
if (inspection.keyInfo.kem !== health.kem) {
return `This public key uses ${inspection.keyInfo.kem}; encryption requires ${health.kem}.`;
}
- if (!validFingerprint(inspection.keyInfo.public_key_fingerprint)) {
+ if (!isPublicKeyFingerprint(inspection.keyInfo.public_key_fingerprint)) {
return "The recipient public key did not provide a valid fingerprint.";
}
- return null;
+ return fingerprintError;
}
const readinessReason = getReadinessReason();
const canStart = !locked && !readinessReason;
@@ -131,7 +129,7 @@ export function BatchEncryptWorkflow({
if (!canStart || !publicKey) return;
setSelectionError(null);
setCancelling(false);
- start(files, publicKey);
+ start(files, publicKey, expectedFingerprint.trim() || undefined);
}
function clearBatch() {
@@ -139,6 +137,7 @@ export function BatchEncryptWorkflow({
clear();
setFiles([]);
setPublicKey(null);
+ setExpectedFingerprint("");
setDownloaded(new Set());
setDownloadErrors({});
setSelectionError(null);
@@ -244,7 +243,9 @@ export function BatchEncryptWorkflow({
)}
- {!locked && readinessReason && {readinessReason}
}
+
+ {!locked && readinessReason && readinessReason !== fingerprintError && {readinessReason}
}
{!items.length && Encrypt batch }
{busy && (
) {
}
describe("EncryptWorkflow", () => {
+ it.each(["QE1-SHA3-256:abc", `QE1-SHA3-256:${"A".repeat(64)}`, `QE1-SHA3-256:${"b".repeat(64)}`, `QE1-SHA3-256:\n${"a".repeat(64)}`])(
+ "blocks encryption when the expected fingerprint is malformed or mismatched (%s)", async (expected) => {
+ const user = userEvent.setup();
+ const encrypt = vi.fn();
+ render( );
+ await prepareEncryption(user);
+ const field = screen.getByLabelText("Expected recipient fingerprint (optional)");
+ expect(field).toHaveValue("");
+ await user.type(field, expected);
+ expect(field).toHaveAttribute("aria-invalid", "true");
+ expect(screen.getByRole("button", { name: "Encrypt file" })).toBeDisabled();
+ fireEvent.submit(field.closest("form")!);
+ expect(encrypt).not.toHaveBeenCalled();
+ }
+ );
+
+ it("trims and forwards the independently entered matching fingerprint", async () => {
+ const user = userEvent.setup();
+ const encrypt = vi.fn().mockResolvedValue({ filename: "report.pqc", blob: new Blob(["encrypted"]) });
+ render( );
+ await prepareEncryption(user);
+ const field = screen.getByRole("textbox", { name: "Expected recipient fingerprint (optional)" });
+ expect(field).toHaveAttribute("rows", "2");
+ await user.type(field, `\n ${TEST_PUBLIC_KEY_FINGERPRINT} \n`);
+ expect(screen.getByText("Expected fingerprint matches the selected public key.")).toBeVisible();
+ await user.click(screen.getByRole("button", { name: "Encrypt file" }));
+ expect(encrypt).toHaveBeenCalledWith(expect.any(File), expect.any(File), "report_encrypted.pqc", expect.any(AbortSignal), TEST_PUBLIC_KEY_FINGERPRINT);
+ });
+
+ it("blocks a supplied expectation on an older service but preserves optional encryption", async () => {
+ const user = userEvent.setup();
+ const encrypt = vi.fn();
+ render( );
+ await prepareEncryption(user);
+ const field = screen.getByLabelText("Expected recipient fingerprint (optional)");
+ await user.type(field, TEST_PUBLIC_KEY_FINGERPRINT);
+ expect(screen.getByText("Restart an updated local service to enforce the expected recipient fingerprint.")).toBeVisible();
+ expect(screen.getByRole("button", { name: "Encrypt file" })).toBeDisabled();
+ expect(screen.queryByText("Expected fingerprint matches the selected public key.")).not.toBeInTheDocument();
+ await user.clear(field);
+ expect(screen.getByRole("button", { name: "Encrypt file" })).toBeEnabled();
+ expect(encrypt).not.toHaveBeenCalled();
+ });
+
+ it("retains the expected fingerprint while revoking stale matches on key changes", async () => {
+ const user = userEvent.setup();
+ let resolveOld!: (value: ReturnType) => void;
+ let resolveCurrent!: (value: ReturnType) => void;
+ const inspect = vi.fn().mockResolvedValueOnce(publicKeyInspection())
+ .mockImplementationOnce(() => new Promise((resolve) => { resolveOld = resolve; }))
+ .mockImplementationOnce(() => new Promise((resolve) => { resolveCurrent = resolve; }));
+ render( );
+ await prepareEncryption(user);
+ const field = screen.getByLabelText("Expected recipient fingerprint (optional)");
+ await user.type(field, TEST_PUBLIC_KEY_FINGERPRINT);
+ expect(screen.getByText("Expected fingerprint matches the selected public key.")).toBeVisible();
+ await user.upload(screen.getByLabelText("Recipient public key"), new File(["old"], "old.pem"));
+ expect(field).toHaveValue(TEST_PUBLIC_KEY_FINGERPRINT);
+ expect(screen.queryByText("Expected fingerprint matches the selected public key.")).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Encrypt file" })).toBeDisabled();
+ await user.upload(screen.getByLabelText("Recipient public key"), new File(["current"], "current.pem"));
+ await act(async () => resolveCurrent({ ...publicKeyInspection(), keyInfo: { ...publicKeyInspection().keyInfo, public_key_fingerprint: `QE1-SHA3-256:${"b".repeat(64)}` } }));
+ await act(async () => resolveOld(publicKeyInspection()));
+ expect(screen.getByText(/The expected fingerprint does not match/)).toBeVisible();
+ expect(screen.queryByText("Expected fingerprint matches the selected public key.")).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Encrypt file" })).toBeDisabled();
+ });
+
it("encrypts with a compatible public key and saves the returned file", async () => {
const inspect = vi.fn().mockResolvedValue(publicKeyInspection());
const encrypt = vi.fn().mockResolvedValue({
diff --git a/web/src/features/encrypt/EncryptWorkflow.tsx b/web/src/features/encrypt/EncryptWorkflow.tsx
index 137406e..d830078 100644
--- a/web/src/features/encrypt/EncryptWorkflow.tsx
+++ b/web/src/features/encrypt/EncryptWorkflow.tsx
@@ -10,12 +10,14 @@ import { isAbortError, safeOperationError } from "../../api/errors";
import { ActionButton } from "../../components/ActionButton";
import { FilePicker } from "../../components/FilePicker";
import { Notice } from "../../components/Notice";
+import { RecipientFingerprintField } from "../../components/RecipientFingerprintField";
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 { isPublicKeyFingerprint, recipientFingerprintError } from "../../lib/recipientFingerprint";
import { deriveWorkflowPhase } from "../../lib/workflow";
export type EncryptWorkflowProps = {
@@ -29,17 +31,6 @@ function limitMessage(label: string, maxBytes: number): string {
return `${label} exceeds the ${maxBytes.toLocaleString()} byte limit.`;
}
-const PUBLIC_KEY_FINGERPRINT_PREFIX = "QE1-SHA3-256:";
-const PUBLIC_KEY_FINGERPRINT_PATTERN = /^QE1-SHA3-256:[0-9a-f]{64}$/;
-
-function isValidPublicKeyFingerprint(value: unknown): value is string {
- return (
- typeof value === "string" &&
- value.length === PUBLIC_KEY_FINGERPRINT_PREFIX.length + 64 &&
- PUBLIC_KEY_FINGERPRINT_PATTERN.test(value)
- );
-}
-
export function EncryptWorkflow({
health,
inspect,
@@ -48,6 +39,7 @@ export function EncryptWorkflow({
}: EncryptWorkflowProps) {
const [file, setFile] = useState(null);
const [publicKey, setPublicKey] = useState(null);
+ const [expectedFingerprint, setExpectedFingerprint] = useState("");
const [outputFilename, setOutputFilename] = useState("");
const [error, setError] = useState(null);
const [completedFilename, setCompletedFilename] = useState(null);
@@ -68,7 +60,7 @@ export function EncryptWorkflow({
const recipientPublicKeyFingerprint =
inspection?.ok &&
inspection.keyInfo.key_type === "public" &&
- isValidPublicKeyFingerprint(inspection.keyInfo.public_key_fingerprint)
+ isPublicKeyFingerprint(inspection.keyInfo.public_key_fingerprint)
? inspection.keyInfo.public_key_fingerprint
: null;
const compatiblePublicKey = Boolean(
@@ -77,6 +69,8 @@ export function EncryptWorkflow({
inspection.keyInfo.kem === health.kem &&
recipientPublicKeyFingerprint
);
+ const fingerprintError = recipientFingerprintError(expectedFingerprint,
+ compatiblePublicKey ? recipientPublicKeyFingerprint : null, health.supportsRecipientFingerprint === true);
const readinessReason = useMemo(() => {
if (!capability.available) return capability.reason;
@@ -91,9 +85,10 @@ export function EncryptWorkflow({
if (inspection.keyInfo.kem !== health.kem) {
return `This public key uses ${inspection.keyInfo.kem}; encryption requires ${health.kem}.`;
}
- if (!isValidPublicKeyFingerprint(inspection.keyInfo.public_key_fingerprint)) {
+ if (!isPublicKeyFingerprint(inspection.keyInfo.public_key_fingerprint)) {
return "The recipient public key did not provide a valid fingerprint.";
}
+ if (fingerprintError) return fingerprintError;
if (!outputFilename.trim()) return "Enter an output filename.";
return null;
}, [
@@ -101,6 +96,7 @@ export function EncryptWorkflow({
capability.reason,
file,
fileError,
+ fingerprintError,
health.kem,
inspection,
inspecting,
@@ -165,7 +161,10 @@ export function EncryptWorkflow({
setCompletedFilename(null);
try {
- const result = await encrypt(file, publicKey, outputFilename.trim(), controller.signal);
+ const expected = expectedFingerprint.trim();
+ const result = expected
+ ? await encrypt(file, publicKey, outputFilename.trim(), controller.signal, expected)
+ : await encrypt(file, publicKey, outputFilename.trim(), controller.signal);
if (!mountedRef.current || requestId !== requestIdRef.current) return;
try {
save(result);
@@ -254,6 +253,10 @@ export function EncryptWorkflow({
encrypting.
)}
+ { setExpectedFingerprint(value); clearOutcome(); }} />
Output filename
- {readinessReason && !busy && readinessReason !== fileError && readinessReason !== keyError && readinessReason !== safeInspectionError && (
+ {readinessReason && !busy && readinessReason !== fileError && readinessReason !== keyError && readinessReason !== safeInspectionError && readinessReason !== fingerprintError && (
{readinessReason}
)}
diff --git a/web/src/features/encrypt/useBatchEncryption.ts b/web/src/features/encrypt/useBatchEncryption.ts
index 9589746..df8b809 100644
--- a/web/src/features/encrypt/useBatchEncryption.ts
+++ b/web/src/features/encrypt/useBatchEncryption.ts
@@ -15,12 +15,14 @@ function encryptionError(error: unknown): string {
export function useBatchEncryption(encrypt: EncryptFileOperation) {
const { items, busy, start: startFiles, cancel, clear } = useBatchFiles(encryptionError);
- const start = useCallback((files: readonly File[], publicKey: File) => {
+ const start = useCallback((files: readonly File[], publicKey: File, expectedRecipientFingerprint?: string) => {
if (files.length === 0 || files.length > MAX_BATCH_FILES) return;
const names = uniqueBatchFilenames(files.map((file) => `${sanitizeBatchFilename(file.name)}.pqc`));
startFiles(
files.map((file, index) => ({ file, outputFilename: names[index] })),
- (file, outputFilename, signal) => encrypt(file, publicKey, outputFilename, signal)
+ (file, outputFilename, signal) => expectedRecipientFingerprint === undefined
+ ? encrypt(file, publicKey, outputFilename, signal)
+ : encrypt(file, publicKey, outputFilename, signal, expectedRecipientFingerprint)
);
}, [encrypt, startFiles]);
const encryptionItems: BatchEncryptionItem[] = items.map((item) => ({
diff --git a/web/src/features/large-files/LargeFilesWorkflow.test.tsx b/web/src/features/large-files/LargeFilesWorkflow.test.tsx
index ef0c478..e766f3d 100644
--- a/web/src/features/large-files/LargeFilesWorkflow.test.tsx
+++ b/web/src/features/large-files/LargeFilesWorkflow.test.tsx
@@ -1,4 +1,4 @@
-import { act, render, screen, waitFor } from "@testing-library/react";
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { KeyInspectResult } from "../../api";
@@ -44,6 +44,60 @@ async function prepare(user: ReturnType, mode: LargeFile
}
describe("LargeFilesWorkflow", () => {
+ it.each([
+ ["QE1-SHA3-256:incomplete", true],
+ [`QE1-SHA3-256:${"b".repeat(64)}`, true],
+ [fingerprint, undefined]
+ ])("blocks file reservation for an invalid, mismatched, or unsupported expectation (%s)", async (expected, supportsRecipientFingerprint) => {
+ const user = userEvent.setup();
+ const api = operations();
+ render( );
+ await prepare(user);
+ const field = screen.getByLabelText("Expected recipient fingerprint (optional)");
+ await user.type(field, expected as string);
+ expect(field).toHaveAttribute("aria-invalid", "true");
+ expect(screen.getByRole("button", { name: "Encrypt large file" })).toBeDisabled();
+ fireEvent.submit(field.closest("form")!);
+ expect(api.create).not.toHaveBeenCalled();
+ expect(api.upload).not.toHaveBeenCalled();
+ expect(api.start).not.toHaveBeenCalled();
+ });
+
+ it("retains the submitted expectation through upload and clears it with the temporary job", async () => {
+ const user = userEvent.setup();
+ const api = operations();
+ const upload = deferred();
+ vi.mocked(api.upload).mockReturnValue(upload.promise);
+ render( );
+ const { key } = await prepare(user);
+ const field = screen.getByLabelText("Expected recipient fingerprint (optional)");
+ await user.type(field, ` ${fingerprint} `);
+ expect(screen.getByText("Expected fingerprint matches the selected public key.")).toBeVisible();
+ await user.click(screen.getByRole("button", { name: "Encrypt large file" }));
+ await waitFor(() => expect(api.upload).toHaveBeenCalled());
+ expect(field).toBeDisabled();
+ fireEvent.change(field, { target: { value: "" } });
+ expect(field).toHaveValue(` ${fingerprint} `);
+ expect(api.start).not.toHaveBeenCalled();
+ await act(async () => upload.resolve(snapshot("encrypt", { state: "ready" })));
+ await screen.findByRole("button", { name: "Download result" });
+ expect(api.start).toHaveBeenCalledWith("job-one", key, "", expect.any(AbortSignal), fingerprint);
+ expect(api.start).toHaveBeenCalledTimes(1);
+ await user.click(screen.getByRole("button", { name: "Clear temporary files" }));
+ expect(field).toHaveValue("");
+ expect(field).toBeEnabled();
+ });
+
+ it("resets the expectation when changing operation mode", async () => {
+ const user = userEvent.setup();
+ render( );
+ await user.type(screen.getByLabelText("Expected recipient fingerprint (optional)"), fingerprint);
+ await user.selectOptions(screen.getByLabelText("Operation"), "decrypt");
+ expect(screen.queryByLabelText("Expected recipient fingerprint (optional)")).not.toBeInTheDocument();
+ await user.selectOptions(screen.getByLabelText("Operation"), "encrypt");
+ expect(screen.getByLabelText("Expected recipient fingerprint (optional)")).toHaveValue("");
+ });
+
it.each([
["encrypting", "Encrypting file."],
["verifying", "Authenticating file."],
diff --git a/web/src/features/large-files/LargeFilesWorkflow.tsx b/web/src/features/large-files/LargeFilesWorkflow.tsx
index d32e63f..5c21ac8 100644
--- a/web/src/features/large-files/LargeFilesWorkflow.tsx
+++ b/web/src/features/large-files/LargeFilesWorkflow.tsx
@@ -5,10 +5,12 @@ import { ActionButton } from "../../components/ActionButton";
import { FilePicker } from "../../components/FilePicker";
import { Notice } from "../../components/Notice";
import { PasswordField } from "../../components/PasswordField";
+import { RecipientFingerprintField } from "../../components/RecipientFingerprintField";
import { WorkflowLayout } from "../../components/WorkflowLayout";
import { useKeyInspection } from "../../hooks/useKeyInspection";
import { terminalJob, useLargeFileJob } from "../../hooks/useLargeFileJob";
import { formatBytes } from "../../lib/format";
+import { isPublicKeyFingerprint, recipientFingerprintError } from "../../lib/recipientFingerprint";
export type LargeFilesWorkflowProps = {
health: Health;
@@ -17,10 +19,6 @@ export type LargeFilesWorkflowProps = {
onSensitiveResultChange?: (pending: boolean) => void;
};
-function canonicalFingerprint(value: unknown): value is string {
- return typeof value === "string" && value.length === "QE1-SHA3-256:".length + 64 && /^QE1-SHA3-256:[0-9a-f]{64}$/.test(value);
-}
-
function runningStatus(phase: string): string {
if (phase === "encrypting") return "Encrypting file.";
if (phase === "verifying") return "Authenticating file.";
@@ -32,6 +30,7 @@ export function LargeFilesWorkflow({ health, inspect, operations = largeFileOper
const [mode, setMode] = useState("encrypt");
const [file, setFile] = useState(null);
const [key, setKey] = useState(null);
+ const [expectedFingerprint, setExpectedFingerprint] = useState("");
const [password, setPassword] = useState("");
const [downloadRequested, setDownloadRequested] = useState(false);
const [downloadError, setDownloadError] = useState(null);
@@ -44,10 +43,13 @@ export function LargeFilesWorkflow({ health, inspect, operations = largeFileOper
const keyError = key && key.size > health.maxPemBytes ? `This key exceeds the ${formatBytes(health.maxPemBytes)} limit.` : null;
const inspection = useKeyInspection(available ? key : null, health.maxPemBytes, inspect);
const publicKey = inspection.result?.ok && inspection.result.keyInfo.key_type === "public" &&
- inspection.result.keyInfo.kem === health.kem && canonicalFingerprint(inspection.result.keyInfo.public_key_fingerprint);
+ inspection.result.keyInfo.kem === health.kem && isPublicKeyFingerprint(inspection.result.keyInfo.public_key_fingerprint);
const privateKey = inspection.result?.ok && inspection.result.keyInfo.key_type === "private" &&
inspection.result.keyInfo.private_key_encrypted === true;
const validKey = mode === "encrypt" ? publicKey : privateKey;
+ const fingerprint = publicKey ? inspection.result?.keyInfo.public_key_fingerprint : null;
+ const fingerprintError = mode === "encrypt"
+ ? recipientFingerprintError(expectedFingerprint, fingerprint, health.supportsRecipientFingerprint === true) : null;
const snapshot = job.job;
const locked = Boolean(snapshot || job.stage || job.restoring);
const pending = Boolean(job.stage || job.restoring || (snapshot && (!terminalJob(snapshot) ||
@@ -58,7 +60,7 @@ export function LargeFilesWorkflow({ health, inspect, operations = largeFileOper
};
const backendCapability = mode === "encrypt" ? health.capabilities.encrypt : health.capabilities.decrypt;
const ready = available && backendCapability.available && !locked && file && !fileError && !keyError &&
- validKey && !inspection.loading && !inspection.error && (mode === "encrypt" || Boolean(password));
+ validKey && !inspection.loading && !inspection.error && !fingerprintError && (mode === "encrypt" || Boolean(password));
const terminal = snapshot ? terminalJob(snapshot) : false;
const progress = job.stage === "uploading" ? job.uploadBytes : snapshot?.processedBytes ?? 0;
const total = job.stage === "uploading" ? file?.size ?? 0 : snapshot?.totalBytes ?? 0;
@@ -66,7 +68,7 @@ export function LargeFilesWorkflow({ health, inspect, operations = largeFileOper
const verification = snapshot?.verification;
const validVerification = snapshot?.mode === "verify" && verification?.ok && verification.verified === true &&
Number.isSafeInteger(verification.bytesVerified) && verification.bytesVerified >= 0 &&
- verification.bytesVerified <= (limits?.maxPlaintextBytes ?? 0) && canonicalFingerprint(verification.publicKeyFingerprint);
+ verification.bytesVerified <= (limits?.maxPlaintextBytes ?? 0) && isPublicKeyFingerprint(verification.publicKeyFingerprint);
const validResult = snapshot?.result && typeof snapshot.result.filename === "string" && snapshot.result.filename.length > 0 &&
Number.isSafeInteger(snapshot.result.bytes) && snapshot.result.bytes >= 0;
@@ -75,7 +77,7 @@ export function LargeFilesWorkflow({ health, inspect, operations = largeFileOper
function selectMode(next: LargeFileMode) {
if (locked) return;
- setMode(next); setFile(null); setKey(null); setPassword(""); setDownloadError(null); setDownloadRequested(false);
+ setMode(next); setFile(null); setKey(null); setPassword(""); setExpectedFingerprint(""); setDownloadError(null); setDownloadRequested(false);
}
function submit(event: FormEvent) {
@@ -83,12 +85,12 @@ export function LargeFilesWorkflow({ health, inspect, operations = largeFileOper
if (!ready || !file || !key) return;
const secret = password;
setPassword(""); setDownloadError(null); setDownloadRequested(false);
- void job.start(mode, file, key, secret);
+ void job.start(mode, file, key, secret, mode === "encrypt" ? expectedFingerprint.trim() || undefined : undefined);
}
async function clear() {
if (await job.clear()) {
- setFile(null); setKey(null); setPassword(""); setDownloadError(null); setDownloadRequested(false);
+ setFile(null); setKey(null); setPassword(""); setExpectedFingerprint(""); setDownloadError(null); setDownloadRequested(false);
}
}
@@ -128,6 +130,8 @@ export function LargeFilesWorkflow({ health, inspect, operations = largeFileOper
Recipient public-key fingerprint
{inspection.result?.keyInfo.public_key_fingerprint}
Compare this complete fingerprint over a separate trusted channel before encrypting.
}
+ {mode === "encrypt" && }
{privateKey && mode !== "encrypt" && Supported encrypted private key; match not yet verified
}
{mode !== "encrypt" && { if (!locked) setPassword(value); }} />}
diff --git a/web/src/hooks/useLargeFileJob.ts b/web/src/hooks/useLargeFileJob.ts
index df5deca..f247a93 100644
--- a/web/src/hooks/useLargeFileJob.ts
+++ b/web/src/hooks/useLargeFileJob.ts
@@ -157,7 +157,7 @@ export function useLargeFileJob(operations: LargeFileOperations = largeFileOpera
if (current(run)) accept(run, next);
}
- async function start(mode: LargeFileMode, file: File, key: File, password: string) {
+ async function start(mode: LargeFileMode, file: File, key: File, password: string, expectedRecipientFingerprint?: string) {
if (!mounted.current || active.current || changing.current) return;
const run: Run = { controller: new AbortController(), job: null, mode, credentials: { key, password }, cancelled: false, epoch: 0, cleanupRequested: false, cleanupPromise: null };
active.current = run;
@@ -184,7 +184,9 @@ export function useLargeFileJob(operations: LargeFileOperations = largeFileOpera
const credentials = run.credentials;
run.credentials = null;
if (!credentials) return;
- const response = operations.start(reservation.id, credentials.key, credentials.password, run.controller.signal);
+ const response = expectedRecipientFingerprint === undefined
+ ? operations.start(reservation.id, credentials.key, credentials.password, run.controller.signal)
+ : operations.start(reservation.id, credentials.key, credentials.password, run.controller.signal, expectedRecipientFingerprint);
credentials.password = "";
const started = await response;
if (current(run) && !run.cancelled) accept(run, started);
diff --git a/web/src/lib/recipientFingerprint.ts b/web/src/lib/recipientFingerprint.ts
new file mode 100644
index 0000000..16658ff
--- /dev/null
+++ b/web/src/lib/recipientFingerprint.ts
@@ -0,0 +1,15 @@
+export function isPublicKeyFingerprint(value: unknown): value is string {
+ return typeof value === "string" && value.length === "QE1-SHA3-256:".length + 64 &&
+ /^QE1-SHA3-256:[0-9a-f]{64}$/.test(value);
+}
+
+export function recipientFingerprintError(expected: string, actual: string | null | undefined, supported: boolean): string | null {
+ const fingerprint = expected.trim();
+ if (!fingerprint) return null;
+ if (!isPublicKeyFingerprint(fingerprint)) {
+ return "Enter the complete fingerprint: QE1-SHA3-256: followed by 64 lowercase hexadecimal characters.";
+ }
+ if (!supported) return "Restart an updated local service to enforce the expected recipient fingerprint.";
+ if (actual && fingerprint !== actual) return "The expected fingerprint does not match the selected public key. Check the key and trusted fingerprint before encrypting.";
+ return null;
+}
diff --git a/web/src/styles/components.css b/web/src/styles/components.css
index 20d5b4c..92ec18f 100644
--- a/web/src/styles/components.css
+++ b/web/src/styles/components.css
@@ -247,7 +247,8 @@ button:disabled {
font-weight: 700;
}
-.output-filename-field input {
+.output-filename-field input,
+.output-filename-field textarea {
min-height: 44px;
min-width: 0;
border: 1px solid var(--color-border);
@@ -258,6 +259,18 @@ button:disabled {
font: inherit;
}
+.output-filename-field .recipient-fingerprint-input {
+ resize: vertical;
+ line-height: 1.5;
+ overflow-wrap: anywhere;
+}
+
+@media (max-width: 560px) {
+ .output-filename-field .recipient-fingerprint-input {
+ min-height: calc(4.5em + var(--space-2) * 2 + 2px);
+ }
+}
+
.workflow-readiness-reason {
margin: 0;
color: var(--color-text-muted);
diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts
index 6bc2256..8dd498d 100644
--- a/web/src/test/fixtures.ts
+++ b/web/src/test/fixtures.ts
@@ -5,6 +5,7 @@ export const READY_HEALTH: Health = {
supportsKeyPasswordChange: true,
supportsPublicKeyRecovery: true,
supportsFileVerification: true,
+ supportsRecipientFingerprint: true,
backendReady: true,
backendMessage: "Post-quantum backend ready.",
capabilities: {