- 薄自建 LLM 网关:OpenAI 兼容适配器(DeepSeek) + instructor 结构化输出 + usage_ledger 记账 + 档位路由 - 记忆服务 assemble:确定性选择(显式+主角+近况) + 渲染卡 + 缓存断点(中性文本) - LangGraph 写章节点 + Postgres checkpointer + SSE 归一(token/done/error) - API:立项 + 写章 draft(SSE) + PUT 自动保存 + 提供商凭据(Fernet 加密/测试连接) - 前端:AppShell + 作品库 + 5 步立项向导 + 写作工作台(流式打字机+自动保存) + 设置页 - M1 E2E:真实 DB + mock 网关零 token 走通闭环
54 lines
2.0 KiB
Python
54 lines
2.0 KiB
Python
"""提供商 API Key 的对称加解密与掩码(ARCH §4.7)。
|
||
|
||
- 加密用 `cryptography` 的 Fernet,密钥取 `settings.credential_enc_key`。
|
||
- 缺失/非法密钥 → 立即失败(`CredentialKeyError`),绝不静默。
|
||
- `mask_api_key` 产出可安全展示/落库回显的掩码;明文永不出响应、永不进日志。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from cryptography.fernet import Fernet, InvalidToken
|
||
|
||
# 掩码常量:前缀 + 省略号 + 末四位。短 key 用 bullet 占位,绝不泄露字符。
|
||
_MASK_PREFIX = "sk-…"
|
||
_MASK_PLACEHOLDER = "••••"
|
||
_LAST_N = 4
|
||
|
||
|
||
class CredentialKeyError(RuntimeError):
|
||
"""加密密钥缺失或非法——快速失败,调用方负责映射为 5xx/配置错误。"""
|
||
|
||
|
||
def _fernet(key: str) -> Fernet:
|
||
if not key:
|
||
raise CredentialKeyError(
|
||
"credential_enc_key 未配置;无法加解密提供商凭据。请设置环境变量。"
|
||
)
|
||
try:
|
||
return Fernet(key.encode())
|
||
except (ValueError, TypeError) as exc:
|
||
# 不回显 key 内容
|
||
raise CredentialKeyError(
|
||
"credential_enc_key 非法(需为 32 字节 url-safe base64 Fernet key)。"
|
||
) from exc
|
||
|
||
|
||
def encrypt_api_key(plaintext: str, *, key: str) -> bytes:
|
||
"""加密明文 API Key,返回可入库的密文字节(`api_key_enc`)。"""
|
||
return _fernet(key).encrypt(plaintext.encode())
|
||
|
||
|
||
def decrypt_api_key(blob: bytes, *, key: str) -> str:
|
||
"""解密密文字节回明文。仅供探测/调用使用,绝不进响应或日志。"""
|
||
try:
|
||
return _fernet(key).decrypt(blob).decode()
|
||
except InvalidToken as exc:
|
||
raise CredentialKeyError("凭据密文无法解密(密钥不匹配或数据损坏)。") from exc
|
||
|
||
|
||
def mask_api_key(plaintext: str) -> str:
|
||
"""掩码:仅露末四位,如 `sk-…1234`;过短/空则全隐。"""
|
||
if len(plaintext) < _LAST_N:
|
||
return f"{_MASK_PREFIX}{_MASK_PLACEHOLDER}"
|
||
return f"{_MASK_PREFIX}{plaintext[-_LAST_N:]}"
|