From 8a55976d002cce4bb6e7160644c5581843f7f8f6 Mon Sep 17 00:00:00 2001 From: "fengchuanhn@gmail.com" Date: Sun, 17 May 2026 22:05:37 +0800 Subject: [PATCH] 11 --- .env.example | 4 + app/config.py | 3 + app/core/aes_crypto.py | 69 ++++++++++++ app/main.py | 2 + app/middleware/__init__.py | 1 + app/middleware/api_crypto.py | 183 +++++++++++++++++++++++++++++++ requirements.txt | 1 + scripts/nodejs/desktop_config.js | 10 +- 8 files changed, 268 insertions(+), 5 deletions(-) create mode 100644 app/core/aes_crypto.py create mode 100644 app/middleware/__init__.py create mode 100644 app/middleware/api_crypto.py diff --git a/.env.example b/.env.example index 26ea73e..0d10dab 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,10 @@ REDIS_URL=redis://127.0.0.1:6379/0 SECRET_KEY=change-me-to-a-long-random-string ACCESS_TOKEN_EXPIRE_MINUTES=10080 +# API 传输 AES-256-GCM(与 Tauri AICLIENT_API_AES_KEY 相同;32 字节 Base64) +# 生成示例: python -c "import os,base64; print(base64.b64encode(os.urandom(32)).decode())" +API_AES_KEY=98cde5e64ee0da6ed0c5842f43a950aa0d8ae5087c43c8d8ee5d10bd8d571bb4 + # CORS(逗号分隔,* 表示允许全部) CORS_ORIGINS=http://localhost:1420,tauri://localhost diff --git a/app/config.py b/app/config.py index b8b56e7..3976ebd 100644 --- a/app/config.py +++ b/app/config.py @@ -25,6 +25,9 @@ class Settings(BaseSettings): secret_key: str = Field(default="change-me-in-production") access_token_expire_minutes: int = 60 * 24 * 7 + """AES-256 密钥:32 字节 Base64 或 64 位十六进制;为空则关闭 API 加解密。""" + api_aes_key: str = "98cde5e64ee0da6ed0c5842f43a950aa0d8ae5087c43c8d8ee5d10bd8d571bb4" + cors_origins: str = "http://localhost:1420" @property diff --git a/app/core/aes_crypto.py b/app/core/aes_crypto.py new file mode 100644 index 0000000..ac8b67d --- /dev/null +++ b/app/core/aes_crypto.py @@ -0,0 +1,69 @@ +"""AES-256-GCM 加解密,与 aiclient Tauri 端一致。""" + +from __future__ import annotations + +import base64 +import json +import os +from typing import Any + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +ENVELOPE_VERSION = 1 +ENCRYPTED_HEADER = "x-aiclient-encrypted" + + +def parse_aes_key(raw: str) -> bytes | None: + """解析 32 字节密钥:标准 Base64 或 64 位十六进制。""" + s = raw.strip() + if not s: + return None + try: + if len(s) == 64 and all(c in "0123456789abcdefABCDEF" for c in s): + key = bytes.fromhex(s) + else: + key = base64.b64decode(s) + if len(key) != 32: + return None + return key + except Exception: + return None + + +def encrypt_bytes(plaintext: bytes, key: bytes) -> str: + nonce = os.urandom(12) + ciphertext = AESGCM(key).encrypt(nonce, plaintext, None) + return base64.b64encode(nonce + ciphertext).decode("ascii") + + +def decrypt_payload(b64_payload: str, key: bytes) -> bytes: + raw = base64.b64decode(b64_payload) + if len(raw) < 13: + raise ValueError("密文过短") + nonce, ciphertext = raw[:12], raw[12:] + return AESGCM(key).decrypt(nonce, ciphertext, None) + + +def wrap_encrypted(plaintext: bytes, key: bytes) -> bytes: + payload = encrypt_bytes(plaintext, key) + return json.dumps({"v": ENVELOPE_VERSION, "payload": payload}, ensure_ascii=False).encode( + "utf-8" + ) + + +def unwrap_encrypted(body: bytes, key: bytes) -> bytes: + obj: dict[str, Any] = json.loads(body) + if obj.get("v") != ENVELOPE_VERSION or "payload" not in obj: + raise ValueError("无效的加密信封") + payload = obj["payload"] + if not isinstance(payload, str): + raise ValueError("payload 应为字符串") + return decrypt_payload(payload, key) + + +def is_encrypted_envelope(body: bytes) -> bool: + try: + obj = json.loads(body) + return isinstance(obj, dict) and "payload" in obj and obj.get("v") == ENVELOPE_VERSION + except Exception: + return False diff --git a/app/main.py b/app/main.py index d2c3a35..462af73 100644 --- a/app/main.py +++ b/app/main.py @@ -5,6 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.v1.router import api_router from app.config import get_settings +from app.middleware.api_crypto import ApiCryptoMiddleware from app.redis_client import close_redis, init_redis from app.schemas.common import ApiResponse @@ -32,6 +33,7 @@ def create_app() -> FastAPI: allow_methods=["*"], allow_headers=["*"], ) + app.add_middleware(ApiCryptoMiddleware) @app.get("/health", response_model=ApiResponse[dict]) async def health() -> ApiResponse[dict]: diff --git a/app/middleware/__init__.py b/app/middleware/__init__.py new file mode 100644 index 0000000..aeb2974 --- /dev/null +++ b/app/middleware/__init__.py @@ -0,0 +1 @@ +"""ASGI 中间件。""" diff --git a/app/middleware/api_crypto.py b/app/middleware/api_crypto.py new file mode 100644 index 0000000..21556c5 --- /dev/null +++ b/app/middleware/api_crypto.py @@ -0,0 +1,183 @@ +"""对 /api/v1 请求体解密、响应体加密(需配置 API_AES_KEY)。 + +使用纯 ASGI 中间件,避免 BaseHTTPMiddleware 读取 body 后缓存导致路由拿不到明文。 +""" + +from __future__ import annotations + +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from app.config import get_settings +from app.core.aes_crypto import ( + ENCRYPTED_HEADER, + is_encrypted_envelope, + parse_aes_key, + unwrap_encrypted, + wrap_encrypted, +) + +_API_PREFIX = "/api/v1" + +_api_aes_key: bytes | None = None +_api_aes_key_initialized = False + + +def get_api_aes_key() -> bytes | None: + print("get_api_aes_key",get_settings().api_aes_key) + global _api_aes_key, _api_aes_key_initialized + if not _api_aes_key_initialized: + _api_aes_key = parse_aes_key(get_settings().api_aes_key) + _api_aes_key_initialized = True + return _api_aes_key + + +async def _read_body(receive: Receive) -> bytes: + body = b"" + more = True + while more: + message = await receive() + if message["type"] != "http.request": + continue + body += message.get("body", b"") + more = message.get("more_body", False) + return body + + +def _make_receive(body: bytes) -> Receive: + sent = False + + async def receive() -> Message: + nonlocal sent + if sent: + return {"type": "http.disconnect"} + sent = True + return {"type": "http.request", "body": body, "more_body": False} + + return receive + + +def _scope_with_content_length(scope: Scope, length: int) -> Scope: + new_scope = dict(scope) + headers: list[tuple[bytes, bytes]] = [] + for name, value in scope.get("headers", []): + if name.lower() != b"content-length": + headers.append((name, value)) + headers.append((b"content-length", str(length).encode())) + new_scope["headers"] = headers + return new_scope + + +def _header_value(scope: Scope, name: str) -> str | None: + target = name.lower().encode() + for key, value in scope.get("headers", []): + if key.lower() == target: + return value.decode("latin-1") + return None + + +def _is_json_response(headers: list[tuple[bytes, bytes]]) -> bool: + for key, value in headers: + if key.lower() == b"content-type": + return b"json" in value.lower() + return False + + +def _filter_response_headers(headers: list[tuple[bytes, bytes]]) -> list[tuple[bytes, bytes]]: + skip = {b"content-length", b"content-encoding"} + return [(k, v) for k, v in headers if k.lower() not in skip] + + +async def _send_plain_json(send: Send, scope: Scope, status: int, payload: bytes) -> None: + await send( + { + "type": "http.response.start", + "status": status, + "headers": [(b"content-type", b"application/json; charset=utf-8")], + } + ) + await send({"type": "http.response.body", "body": payload, "more_body": False}) + + +def _encrypting_send(send: Send, key: bytes) -> Send: + status = 200 + headers: list[tuple[bytes, bytes]] = [] + chunks: list[bytes] = [] + started = False + + async def wrapper(message: Message) -> None: + nonlocal status, headers, started + if message["type"] == "http.response.start": + started = True + status = message["status"] + headers = list(message.get("headers", [])) + return + + if message["type"] != "http.response.body": + await send(message) + return + + chunks.append(message.get("body", b"")) + if message.get("more_body", False): + return + + body = b"".join(chunks) + out_headers = _filter_response_headers(headers) + if _is_json_response(headers): + try: + body = wrap_encrypted(body, key) + except Exception as exc: + err = ( + '{"ok":false,"message":"响应加密失败: ' + + str(exc).replace('"', '\\"') + + '"}' + ).encode("utf-8") + await _send_plain_json(send, {}, 500, err) + return + out_headers.append((ENCRYPTED_HEADER.encode(), b"1")) + + out_headers.append((b"content-length", str(len(body)).encode())) + await send({"type": "http.response.start", "status": status, "headers": out_headers}) + await send({"type": "http.response.body", "body": body, "more_body": False}) + + return wrapper + + +class ApiCryptoMiddleware: + """纯 ASGI 中间件(勿使用 BaseHTTPMiddleware)。""" + + 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 + + key = get_api_aes_key() + path = scope.get("path", "") + + if key is None or not path.startswith(_API_PREFIX): + + await self.app(scope, receive, send) + return + + method = scope.get("method", "GET") + + if method in ("POST", "PUT", "PATCH"): + body = await _read_body(receive) + if ( + body + and _header_value(scope, ENCRYPTED_HEADER) == "1" + and is_encrypted_envelope(body) + ): + try: + body = unwrap_encrypted(body, key) + except Exception as exc: + err = f'{{"ok":false,"message":"请求解密失败: {exc}"}}'.encode("utf-8") + await _send_plain_json(send, scope, 400, err) + return + + receive = _make_receive(body) + scope = _scope_with_content_length(scope, len(body)) + + await self.app(scope, receive, _encrypting_send(send, key)) diff --git a/requirements.txt b/requirements.txt index 524a845..f450331 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,5 +8,6 @@ alembic>=1.14.0,<2.0 redis>=5.2.0,<6.0 bcrypt>=4.0.0,<6.0 python-jose[cryptography]>=3.3.0,<4.0 +cryptography>=43.0.0,<45.0 python-multipart>=0.0.12,<1.0 greenlet>=3.1.0,<4.0 diff --git a/scripts/nodejs/desktop_config.js b/scripts/nodejs/desktop_config.js index 157a03c..2794976 100644 --- a/scripts/nodejs/desktop_config.js +++ b/scripts/nodejs/desktop_config.js @@ -54,7 +54,7 @@ function buildModelConfig() { return ""; }; - let apiUrl = pick("LLM_API_URL", "API_URL"); + let apiUrl = pick("BAILIAN_BASE_URL", "BAILIAN_BASE_URL"); if (apiUrl) { apiUrl = apiUrl.replace(/\/$/, ""); if (!apiUrl.includes("/chat/completions")) { @@ -87,13 +87,13 @@ function buildModelConfig() { } return { - providerId: pick("LLM_PROVIDER_ID", "PROVIDER_ID") || "openai", + providerId: pick("LLM_PROVIDER_ID", "PROVIDER_ID") || "bailian", modelId: pick("LLM_MODEL_ID", "MODEL_ID"), apiUrl, - apiKey: pick("LLM_API_KEY", "API_KEY"), - type: pick("LLM_TYPE", "TYPE") || "openai", + apiKey: pick("BAILIAN_API_KEY", "API_KEY"), + type: pick("LLM_TYPE", "TYPE") || "bailian", asrMode, - aliyunApiKey: pick("DASHSCOPE_API_KEY", "ALIYUN_API_KEY"), + aliyunApiKey: pick("BAILIAN_API_KEY", "BAILIAN_API_KEY"), ossConfig: ossConfig || undefined, asrServerInfo: asrServerInfo || undefined, };