diff --git a/CHANGELOG.md b/CHANGELOG.md index efd4998..c88fea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api_app.py b/api_app.py index 465d1d4..394e7a3 100644 --- a/api_app.py +++ b/api_app.py @@ -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 @@ -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: @@ -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", "")) @@ -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 @@ -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) 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 @@ -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.""" @@ -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, @@ -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 @@ -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 diff --git a/docs/API.md b/docs/API.md index 0ea8946..997ccd5 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 @@ -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 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 2c5a62b..07fe4b1 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -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. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index c70c3ab..12f9910 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -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. diff --git a/scripts/api_client.test.mjs b/scripts/api_client.test.mjs index 30af02e..0f2ddf4 100644 --- a/scripts/api_client.test.mjs +++ b/scripts/api_client.test.mjs @@ -33,7 +33,7 @@ async function loadApiModule() { return import(`${pathToFileURL(fileURLToPath(clientPath)).href}#${moduleSequence}`); } -function healthPayload(apiToken) { +function healthPayload() { return { ok: true, backendReady: true, @@ -52,7 +52,6 @@ function healthPayload(apiToken) { maxFileBytes: 104857600, maxEncryptedFileBytes: 104989748, maxPemBytes: 131072, - apiToken, passwordPolicy: { minChars: 16, minUniqueChars: 5 @@ -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) => { @@ -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(); @@ -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) => { @@ -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]), { @@ -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); }); diff --git a/scripts/ui_smoke.mjs b/scripts/ui_smoke.mjs index 2255ab8..1f846e9 100644 --- a/scripts/ui_smoke.mjs +++ b/scripts/ui_smoke.mjs @@ -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, @@ -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 } }; } diff --git a/tests/test_api_app.py b/tests/test_api_app.py index fd84fc2..9b47de5 100644 --- a/tests/test_api_app.py +++ b/tests/test_api_app.py @@ -31,7 +31,7 @@ def test_health_payload_is_safe_without_required_native_backend(): assert payload["configuredKem"] == cfg.KEM_ALG assert payload["kem"] == cfg.HYBRID_KEM_ALG assert payload["dem"] == "AES-256-GCM" - assert payload["apiToken"] == api_app.LOCAL_API_TOKEN + assert "apiToken" not in payload assert payload["maxFileBytes"] == cfg.MAX_FILE_BYTES assert payload["passwordPolicy"]["minChars"] == cfg.PRIVATE_KEY_MIN_PASSWORD_CHARS @@ -266,17 +266,55 @@ def test_read_upload_bytes_rejects_oversized_upload(): asyncio.run(upload.close()) -def test_health_route_returns_local_api_token(): +def test_health_route_sets_auth_cookie_without_disclosing_token(): status, response_headers, response_body = asyncio.run(_call_app_raw("/api/health", method="GET")) payload = json.loads(response_body.decode("utf-8")) assert status == 200 assert payload["ok"] is True - assert payload["apiToken"] == api_app.LOCAL_API_TOKEN + assert "apiToken" not in payload + set_cookie = _header(response_headers, b"set-cookie") + assert set_cookie is not None + assert f"{api_app.LOCAL_API_TOKEN_COOKIE}={api_app.LOCAL_API_TOKEN}" in set_cookie + assert "HttpOnly" in set_cookie + assert "SameSite=strict" in set_cookie assert _header(response_headers, b"cache-control") == "no-store" assert _header(response_headers, b"pragma") == "no-cache" +def test_security_headers_are_applied_to_api_responses(): + status, response_headers, _body = asyncio.run(_call_app_raw("/api/health", method="GET")) + + assert status == 200 + csp = _header(response_headers, b"content-security-policy") + assert csp is not None + assert "default-src 'self'" in csp + assert "frame-ancestors 'none'" in csp + assert _header(response_headers, b"x-content-type-options") == "nosniff" + assert _header(response_headers, b"x-frame-options") == "DENY" + assert _header(response_headers, b"referrer-policy") == "no-referrer" + + +def test_security_headers_cover_middleware_rejections(): + body, headers = _multipart_body("key", "bad.pem", b"not a supported key") + + status, response_headers, _body = asyncio.run(_call_app_raw("/api/keys/inspect", body=body, headers=headers)) + + assert status == 403 + assert _header(response_headers, b"x-content-type-options") == "nosniff" + assert _header(response_headers, b"content-security-policy") is not None + + +def test_post_api_accepts_auth_cookie(): + body, headers = _multipart_body("key", "bad.pem", b"not a supported key") + headers.append((b"cookie", f"{api_app.LOCAL_API_TOKEN_COOKIE}={api_app.LOCAL_API_TOKEN}".encode("ascii"))) + + status, payload = asyncio.run(_call_app("/api/keys/inspect", body=body, headers=headers)) + + assert status == 400 + assert payload["error_code"] == "unsupported_key" + + def test_post_api_rejects_missing_local_api_token(): body, headers = _multipart_body("key", "bad.pem", b"not a supported key") diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 9627710..3339199 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -6,7 +6,7 @@ type ApiErrorPayload = { message: string; }; -let localApiToken = ""; +let healthLoaded = false; let healthRequest: Promise | null = null; export class ApiError extends Error { @@ -20,16 +20,6 @@ export class ApiError extends Error { } } -async function apiTokenHeaders(): Promise { - if (!localApiToken) { - await fetchHealth(); - } - if (!localApiToken) { - throw new ApiError(503, "missing_api_token", "Local API token is not available."); - } - return { "X-Quantum-Encryptor-Token": localApiToken }; -} - async function rejectedApiToken(response: Response): Promise { if (response.status !== 403) return false; try { @@ -40,24 +30,24 @@ async function rejectedApiToken(response: Response): Promise { } } -async function fetchWithApiToken(input: RequestInfo | URL, init: RequestInit): Promise { - const send = async (): Promise<{ response: Response; token: string }> => { - const headers = new Headers(init.headers); - const tokenHeaders = new Headers(await apiTokenHeaders()); - const token = tokenHeaders.get("X-Quantum-Encryptor-Token") || ""; - headers.set("X-Quantum-Encryptor-Token", token); - return { response: await fetch(input, { ...init, headers }), token }; - }; +async function ensureHealth(): Promise { + // The first health fetch sets the per-process HttpOnly auth cookie; the browser + // attaches it to same-origin state-changing requests automatically. + if (!healthLoaded) { + await fetchHealth(); + healthLoaded = true; + } +} - let attempt = await send(); - if (await rejectedApiToken(attempt.response)) { - if (localApiToken === attempt.token) { - localApiToken = ""; - await fetchHealth(); - } - attempt = await send(); +async function fetchStateChanging(input: RequestInfo | URL, init: RequestInit): Promise { + 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(); + response = await fetch(input, init); } - return attempt.response; + return response; } function filenameFromDisposition(disposition: string | null, fallback: string): string { @@ -84,9 +74,7 @@ export async function fetchHealth(): Promise { healthRequest = fetch("/api/health") .then(async (response) => { if (!response.ok) await parseError(response); - const payload = (await response.json()) as Health; - localApiToken = payload.apiToken; - return payload; + return (await response.json()) as Health; }) .finally(() => { healthRequest = null; @@ -98,7 +86,7 @@ export async function fetchHealth(): Promise { export async function inspectKey(file: File, signal?: AbortSignal): Promise { const form = new FormData(); form.append("key", file); - const response = await fetchWithApiToken("/api/keys/inspect", { + const response = await fetchStateChanging("/api/keys/inspect", { method: "POST", body: form, signal @@ -110,7 +98,7 @@ export async function inspectKey(file: File, signal?: AbortSignal): Promise { const form = new FormData(); form.append("password", password); - const response = await fetchWithApiToken("/api/keys/generate", { method: "POST", body: form, signal }); + const response = await fetchStateChanging("/api/keys/generate", { method: "POST", body: form, signal }); if (!response.ok) await parseError(response); return (await response.json()) as GeneratedKeys; } @@ -125,7 +113,7 @@ export async function encryptFile( form.append("file", file); form.append("public_key", publicKey); form.append("output_filename", outputFilename); - const response = await fetchWithApiToken("/api/files/encrypt", { method: "POST", body: form, signal }); + const response = await fetchStateChanging("/api/files/encrypt", { method: "POST", body: form, signal }); if (!response.ok) await parseError(response); return { blob: await response.blob(), @@ -145,7 +133,7 @@ export async function decryptFile( form.append("private_key", privateKey); form.append("password", password); form.append("output_filename", outputFilename); - const response = await fetchWithApiToken("/api/files/decrypt", { method: "POST", body: form, signal }); + const response = await fetchStateChanging("/api/files/decrypt", { method: "POST", body: form, signal }); 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 4aae2a1..75e81f4 100644 --- a/web/src/api/contracts.ts +++ b/web/src/api/contracts.ts @@ -18,7 +18,6 @@ export type Health = { maxFileBytes: number; maxEncryptedFileBytes: number; maxPemBytes: number; - apiToken: string; passwordPolicy: { minChars: number; minUniqueChars: number; diff --git a/web/src/test/fixtures.ts b/web/src/test/fixtures.ts index 6d57f2d..095dbe2 100644 --- a/web/src/test/fixtures.ts +++ b/web/src/test/fixtures.ts @@ -18,7 +18,6 @@ export const READY_HEALTH: Health = { maxFileBytes: 104857600, maxEncryptedFileBytes: 105906314, maxPemBytes: 131072, - apiToken: "test-local-token", passwordPolicy: { minChars: 16, minUniqueChars: 5