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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ This project follows a practical semantic-versioning style.
- Authenticated format-v3 containers and v2 ML-KEM private keys remain decrypt-only for migration.
- New keys and ciphertexts use the unambiguous `ML-KEM-768+X25519-v2` suite and require exact ML-KEM-768 support; Kyber768 is no longer treated as an interchangeable alias.
- Legacy hybrid archives remain recoverable through a bounded ML-KEM/Kyber fallback whose result is accepted only after AES-GCM authentication; legacy hybrid public keys are rejected for new encryption.
- The per-process local API token is no longer disclosed in the `GET /api/health` response body; it is delivered only as an `HttpOnly`, `SameSite=Strict` cookie, with the `X-Quantum-Encryptor-Token` header retained for programmatic clients configured via `QUANTUM_ENCRYPTOR_API_TOKEN`.
- All local web responses now include a restrictive Content Security Policy (including `frame-ancestors 'none'`), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and `Referrer-Policy: no-referrer`.

## [1.0.1] - 2026-06-21

Expand Down
69 changes: 66 additions & 3 deletions api_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import re
import secrets
import sys
from http.cookies import SimpleCookie
from pathlib import Path
from typing import Any
from urllib.parse import quote
Expand All @@ -30,10 +31,22 @@
MULTIPART_OVERHEAD_BYTES = 1024 * 1024
SMALL_FORM_MAX_BYTES = 64 * 1024
LOCAL_API_TOKEN = os.environ.get("QUANTUM_ENCRYPTOR_API_TOKEN") or secrets.token_urlsafe(32)
LOCAL_API_TOKEN_COOKIE = "qe_api_token"
ALLOWED_ORIGIN_PREFIXES = (
"http://127.0.0.1:",
"http://localhost:",
)
SECURITY_HEADERS = (
(
b"content-security-policy",
b"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
b"img-src 'self' data:; connect-src 'self'; font-src 'self' data:; "
b"object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
),
(b"x-content-type-options", b"nosniff"),
(b"x-frame-options", b"DENY"),
(b"referrer-policy", b"no-referrer"),
)


def _static_app_dir() -> Path:
Expand Down Expand Up @@ -158,6 +171,19 @@ def _header_value(scope: Scope, name: bytes) -> str | None:
return None


def _cookie_value(scope: Scope, name: str) -> str | None:
cookie_header = _header_value(scope, b"cookie")
if not cookie_header:
return None
jar = SimpleCookie()
try:
jar.load(cookie_header)
except Exception:
return None
morsel = jar.get(name)
return morsel.value if morsel is not None else None


def _is_state_changing_api(scope: Scope) -> bool:
method = str(scope.get("method", "GET")).upper()
path = str(scope.get("path", ""))
Expand All @@ -177,7 +203,12 @@ def _has_valid_local_api_token(token: str | None) -> bool:


class LocalApiGuardMiddleware:
"""Require local browser context plus per-process token for state-changing API requests."""
"""Require local browser context plus per-process token for state-changing API requests.

The token is accepted from the HttpOnly auth cookie set by ``GET /api/health`` or from
the ``X-Quantum-Encryptor-Token`` header for clients configured with
``QUANTUM_ENCRYPTOR_API_TOKEN``. It is never disclosed in API response bodies.
"""

def __init__(self, app: ASGIApp) -> None:
self.app = app
Expand All @@ -192,7 +223,7 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
await _json_error(ApiError(403, "forbidden_origin", "Request origin is not allowed."))(scope, receive, send)
return

token = _header_value(scope, b"x-quantum-encryptor-token")
token = _header_value(scope, b"x-quantum-encryptor-token") or _cookie_value(scope, LOCAL_API_TOKEN_COOKIE)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bind cookie-authenticated origins to the serving port

When untrusted browser content is served from another port on the same loopback host, the browser sends this host-scoped cookie because cookie and SameSite boundaries ignore ports, while _is_allowed_origin accepts every 127.0.0.1:* or localhost:* origin. Such a page can therefore issue authenticated state-changing requests without learning the HttpOnly token, unlike the previous header-based scheme. Validate cookie-authenticated requests against the API's exact origin/port rather than any loopback port.

Useful? React with 👍 / 👎.

if not _has_valid_local_api_token(token):
await _json_error(ApiError(403, "missing_api_token", "Missing or invalid local API token."))(
scope, receive, send
Expand All @@ -202,6 +233,29 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
await self.app(scope, receive, send)


class SecurityHeadersMiddleware:
"""Apply browser hardening headers to every HTTP response, including middleware errors."""

def __init__(self, app: ASGIApp) -> None:
self.app = app

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return

async def send_with_security_headers(message: Message) -> None:
if message["type"] == "http.response.start":
headers = message.setdefault("headers", [])
existing = {name.lower() for name, _value in headers}
for name, value in SECURITY_HEADERS:
if name not in existing:
headers.append((name, value))
await send(message)

await self.app(scope, receive, send_with_security_headers)


class ApiBodyLimitMiddleware:
"""Reject oversized API bodies before multipart parsing can spool uploads."""

Expand Down Expand Up @@ -354,7 +408,6 @@ def _health_payload() -> dict[str, Any]:
"maxFileBytes": cfg.MAX_FILE_BYTES,
"maxEncryptedFileBytes": cfg.MAX_ENCRYPTED_FILE_BYTES,
"maxPemBytes": cfg.MAX_PEM_BYTES,
"apiToken": LOCAL_API_TOKEN,
"passwordPolicy": {
"minChars": cfg.PRIVATE_KEY_MIN_PASSWORD_CHARS,
"minUniqueChars": cfg.PRIVATE_KEY_MIN_UNIQUE_CHARS,
Expand All @@ -364,6 +417,15 @@ def _health_payload() -> dict[str, Any]:

async def health(_request: Request) -> JSONResponse:
response = _success_json(_health_payload())
# Deliver the per-process API token only as an HttpOnly, SameSite=Strict cookie so it
# is never exposed in response bodies or to JavaScript, and is not sent cross-site.
response.set_cookie(
LOCAL_API_TOKEN_COOKIE,
LOCAL_API_TOKEN,
path="/",
httponly=True,
samesite="strict",
)
response.headers["Cache-Control"] = "no-store"
response.headers["Pragma"] = "no-cache"
return response
Expand Down Expand Up @@ -542,6 +604,7 @@ def create_app() -> Starlette:
app = Starlette(debug=False, routes=routes)
app.add_middleware(ApiBodyLimitMiddleware)
app.add_middleware(LocalApiGuardMiddleware)
app.add_middleware(SecurityHeadersMiddleware)
return app


Expand Down
6 changes: 3 additions & 3 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ Private-key operations read passwords from an environment variable. The default

## Local Web API

The custom web UI is served by `api_app.py` on `127.0.0.1` by default. `GET /api/health` returns a per-process `apiToken` used by the same-origin frontend. Every state-changing `/api/*` request must include that value in the `X-Quantum-Encryptor-Token` header.
The custom web UI is served by `api_app.py` on `127.0.0.1` by default. `GET /api/health` sets the per-process local API token as an `HttpOnly`, `SameSite=Strict` cookie used by the same-origin frontend; the token is never disclosed in API response bodies. Every state-changing `/api/*` request must carry that cookie, or present the token in the `X-Quantum-Encryptor-Token` header (for programmatic clients configured with `QUANTUM_ENCRYPTOR_API_TOKEN`).

When a browser sends an `Origin` header on a state-changing API request, the server only accepts local origins beginning with `http://127.0.0.1:` or `http://localhost:`. Requests without the local API token are rejected before route handlers parse uploaded files or form data.
When a browser sends an `Origin` header on a state-changing API request, the server only accepts local origins beginning with `http://127.0.0.1:` or `http://localhost:`. Requests without the local API token are rejected before route handlers parse uploaded files or form data. All responses include a restrictive `Content-Security-Policy` (including `frame-ancestors 'none'`), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and `Referrer-Policy: no-referrer`.

### Health capability contract

Expand All @@ -54,7 +54,7 @@ When a browser sends an `Origin` header on a state-changing API request, the ser
}
```

`backendReady` and `backendMessage` remain in the response for compatibility, while new clients use operation-specific capabilities. A capability `reason` is a safe user-facing summary of an unavailable operation; it is not a raw backend exception. The health response is marked `Cache-Control: no-store` and `Pragma: no-cache` because it includes the per-process local API token.
`backendReady` and `backendMessage` remain in the response for compatibility, while new clients use operation-specific capabilities. A capability `reason` is a safe user-facing summary of an unavailable operation; it is not a raw backend exception. The health response is marked `Cache-Control: no-store` and `Pragma: no-cache` because it sets the per-process local API token cookie.

### Agent JSON Contract

Expand Down
2 changes: 1 addition & 1 deletion docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ The application is designed to protect against the following threats:
- File decryption requires an exact private-key/container suite label match. The legacy `ML-KEM-768+X25519` label is decrypt-only; its bounded ML-KEM/Kyber migration fallback accepts a candidate only after AES-GCM authentication succeeds. Format-v3 containers use their exact stored KEM identity.
- Legacy hybrid public keys are rejected for encryption and must be regenerated under the current suite.
- The UI and core encryption path enforce a 100 MiB plaintext in-memory file limit; decryption accepts only the bounded encrypted-container size needed for header, KEM ciphertext, nonce, and tag overhead.
- The local web API requires a per-process `X-Quantum-Encryptor-Token` for state-changing `/api/*` requests and rejects non-local browser origins when an `Origin` header is present.
- The local web API requires a per-process token for state-changing `/api/*` requests and rejects non-local browser origins when an `Origin` header is present. The token is delivered only as an `HttpOnly`, `SameSite=Strict` cookie (or supplied via `QUANTUM_ENCRYPTOR_API_TOKEN` for programmatic clients) and is never disclosed in API response bodies. All responses carry a restrictive Content Security Policy with `frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and `Referrer-Policy: no-referrer`.
- Download filenames are reduced to local filenames before the API returns them to the browser.
- Private-key password protection requires at least 16 characters, at least 5 unique characters, and rejects known weak values in the core, UI, and agent CLI.
- Unencrypted private keys are rejected in the core, UI, and agent CLI.
Expand Down
4 changes: 4 additions & 0 deletions docs/THREAT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,15 @@ Quantum Encryptor protects local files with post-quantum key encapsulation and a
- AES-256-GCM with full encrypted-file header as associated data.
- Lazy native `liboqs` loading with dependency failures reported as unavailable backend state.
- Workspace-only agent CLI path validation, exclusive non-overwrite creation, atomic replacement on explicit overwrite, and JSON-only responses.
- The per-process local API token is delivered only as an `HttpOnly`, `SameSite=Strict` cookie and never appears in API response bodies, so cross-site requests cannot carry it and page JavaScript cannot read it.
- All local web responses include a restrictive Content Security Policy with `frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and `Referrer-Policy: no-referrer`, blocking clickjacking of the local UI.

## Limitations

- The app processes files in memory and is not suitable for very large streaming workflows.
- Python cannot guarantee secure zeroization of immutable secret byte strings.
- The loopback API trusts the local machine: any local process can call `GET /api/health` and read the auth cookie from the response headers, so the token guards against browser-based cross-site attacks, not against malicious local software. Keep the server bound to `127.0.0.1`; never expose it on a network interface.
- The local web API does not rate-limit private-key password attempts. Each attempt costs a full scrypt derivation, and an attacker holding the encrypted PEM would brute force offline instead, so online throttling adds little.
- No independent cryptographic audit or formal verification has been performed.
- The application-specific file and key formats are not interoperable with OpenPGP or another standardized container format.
- The hybrid construction has not undergone an independent cryptographic audit.
Expand Down
59 changes: 29 additions & 30 deletions scripts/api_client.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ async function loadApiModule() {
return import(`${pathToFileURL(fileURLToPath(clientPath)).href}#${moduleSequence}`);
}

function healthPayload(apiToken) {
function healthPayload() {
return {
ok: true,
backendReady: true,
Expand All @@ -52,7 +52,6 @@ function healthPayload(apiToken) {
maxFileBytes: 104857600,
maxEncryptedFileBytes: 104989748,
maxPemBytes: 131072,
apiToken,
passwordPolicy: {
minChars: 16,
minUniqueChars: 5
Expand Down Expand Up @@ -85,38 +84,38 @@ test("a token rejection refreshes health and retries the state-changing request
});

const calls = [];
let healthRequests = 0;
let generateRequests = 0;
globalThis.fetch = async (input, init = {}) => {
const url = String(input);
const token = new Headers(init.headers).get("X-Quantum-Encryptor-Token");
calls.push({ url, token });
calls.push({ url, headers: new Headers(init.headers) });

if (url === "/api/health") {
healthRequests += 1;
return jsonResponse(healthPayload(healthRequests === 1 ? "stale-token" : "fresh-token"));
return jsonResponse(healthPayload());
}
if (url === "/api/keys/generate" && token === "stale-token") {
return jsonResponse(
{ ok: false, error_code: "missing_api_token", message: "Local API token is invalid." },
403
);
}
if (url === "/api/keys/generate" && token === "fresh-token") {
if (url === "/api/keys/generate") {
generateRequests += 1;
if (generateRequests === 1) {
return jsonResponse(
{ ok: false, error_code: "missing_api_token", message: "Local API token is invalid." },
403
);
}
return jsonResponse(generatedKeysPayload());
}
throw new Error(`Unexpected request: ${url} (${token})`);
throw new Error(`Unexpected request: ${url}`);
};

const api = await loadApiModule();
const result = await api.generateKeys("correct horse battery staple");

assert.equal(result.publicFilename, "quantum_public_key.pem");
assert.deepEqual(calls, [
{ url: "/api/health", token: null },
{ url: "/api/keys/generate", token: "stale-token" },
{ url: "/api/health", token: null },
{ url: "/api/keys/generate", token: "fresh-token" }
]);
assert.deepEqual(
calls.map(({ url }) => url),
["/api/health", "/api/keys/generate", "/api/health", "/api/keys/generate"]
);
for (const { headers } of calls) {
assert.equal(headers.get("X-Quantum-Encryptor-Token"), null);
}
});

test("an unrelated authorization rejection is not retried", async (t) => {
Expand All @@ -128,13 +127,12 @@ test("an unrelated authorization rejection is not retried", async (t) => {
const calls = [];
globalThis.fetch = async (input, init = {}) => {
const url = String(input);
const token = new Headers(init.headers).get("X-Quantum-Encryptor-Token");
calls.push({ url, token });
if (url === "/api/health") return jsonResponse(healthPayload("current-token"));
calls.push({ url });
if (url === "/api/health") return jsonResponse(healthPayload());
if (url === "/api/keys/generate") {
return jsonResponse({ ok: false, error_code: "origin_forbidden", message: "Origin is not allowed." }, 403);
}
throw new Error(`Unexpected request: ${url} (${token})`);
throw new Error(`Unexpected request: ${url}`);
};

const api = await loadApiModule();
Expand All @@ -143,10 +141,10 @@ test("an unrelated authorization rejection is not retried", async (t) => {
api.generateKeys("correct horse battery staple"),
(error) => error instanceof api.ApiError && error.code === "origin_forbidden"
);
assert.deepEqual(calls, [
{ url: "/api/health", token: null },
{ url: "/api/keys/generate", token: "current-token" }
]);
assert.deepEqual(
calls.map(({ url }) => url),
["/api/health", "/api/keys/generate"]
);
});

test("sensitive operation signals reach each state-changing fetch", async (t) => {
Expand All @@ -159,7 +157,7 @@ test("sensitive operation signals reach each state-changing fetch", async (t) =>
globalThis.fetch = async (input, init = {}) => {
const url = String(input);
calls.push({ url, signal: init.signal });
if (url === "/api/health") return jsonResponse(healthPayload("current-token"));
if (url === "/api/health") return jsonResponse(healthPayload());
if (url === "/api/keys/generate") return jsonResponse(generatedKeysPayload());
if (url === "/api/files/encrypt" || url === "/api/files/decrypt") {
return new Response(new Blob([url]), {
Expand Down Expand Up @@ -187,4 +185,5 @@ test("sensitive operation signals reach each state-changing fetch", async (t) =>
{ url: "/api/files/decrypt", signal: controller.signal }
]
);
assert.equal(calls.filter(({ url }) => url === "/api/health").length, 1);
});
2 changes: 0 additions & 2 deletions scripts/ui_smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import { chromium } from "playwright";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const outDir = path.join(rootDir, "tmp", "ui-smoke");
const baseUrl = process.env.UI_SMOKE_URL ?? "http://127.0.0.1:4000/";
const localToken = "ui-smoke-local-token";

const publicInspection = {
ok: true,
Expand Down Expand Up @@ -70,7 +69,6 @@ function health({ limited = false, strictFileLimit = false } = {}) {
maxFileBytes: strictFileLimit ? 3 : 104857600,
maxEncryptedFileBytes: 105906314,
maxPemBytes: 131072,
apiToken: localToken,
passwordPolicy: { minChars: 16, minUniqueChars: 5 }
};
}
Expand Down
Loading
Loading