107 lines
3.6 KiB
Python
107 lines
3.6 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""模型密钥的落库加密。
|
|
|
|
模型清单里存的是各家厂商的 API Key。这些密钥现在明文躺在每台客户端的
|
|
ai_settings.json 里——收进后端本来就是为了解决这个问题,如果后端再明文存一遍,
|
|
等于把泄漏面从"很多台客户端"换成"一台服务器 + 一个数据库备份文件",并没有真的
|
|
变安全。
|
|
|
|
主密钥单独放一个 0600 的文件,不进数据库、不进代码库、不进备份脚本的默认范围。
|
|
数据库整个被拖走也解不开密钥。
|
|
|
|
用 AES-GCM:带认证标签,密文被改过会解密失败而不是悄悄返回一串垃圾——那种
|
|
"垃圾密钥"会变成一整天查不出来的 401。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import os
|
|
import secrets
|
|
from pathlib import Path
|
|
|
|
_KEY_ENV = "WECOM_BACKEND_SECRET_KEY"
|
|
_KEY_FILENAME = "backend_secret.key"
|
|
|
|
|
|
class SecretBoxError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def _key_path(data_dir: Path | str) -> Path:
|
|
return Path(data_dir) / _KEY_FILENAME
|
|
|
|
|
|
def load_or_create_key(data_dir: Path | str) -> bytes:
|
|
"""取主密钥。环境变量优先,方便容器部署不落盘。"""
|
|
from_env = os.environ.get(_KEY_ENV, "").strip()
|
|
if from_env:
|
|
try:
|
|
key = base64.urlsafe_b64decode(from_env + "=" * (-len(from_env) % 4))
|
|
except Exception as exc:
|
|
raise SecretBoxError(f"{_KEY_ENV} 不是合法的 base64") from exc
|
|
if len(key) != 32:
|
|
raise SecretBoxError(f"{_KEY_ENV} 解出来不是 32 字节")
|
|
return key
|
|
|
|
path = _key_path(data_dir)
|
|
if path.exists():
|
|
key = path.read_bytes().strip()
|
|
key = base64.urlsafe_b64decode(key + b"=" * (-len(key) % 4))
|
|
if len(key) != 32:
|
|
raise SecretBoxError(f"{path} 里的主密钥长度不对,拒绝继续")
|
|
return key
|
|
|
|
key = secrets.token_bytes(32)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
# 先按 0600 建文件再写内容,避免密钥在世界可读的瞬间落盘
|
|
handle = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
try:
|
|
os.write(handle, base64.urlsafe_b64encode(key))
|
|
finally:
|
|
os.close(handle)
|
|
try:
|
|
os.chmod(str(path), 0o600)
|
|
except OSError:
|
|
pass
|
|
return key
|
|
|
|
|
|
def encrypt(plaintext: str, key: bytes) -> str:
|
|
"""→ base64(nonce | tag | 密文)。空串原样返回空串。"""
|
|
text = str(plaintext or "")
|
|
if not text:
|
|
return ""
|
|
from Crypto.Cipher import AES
|
|
|
|
nonce = secrets.token_bytes(12)
|
|
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
|
body, tag = cipher.encrypt_and_digest(text.encode("utf-8"))
|
|
return base64.urlsafe_b64encode(nonce + tag + body).decode("ascii")
|
|
|
|
|
|
def decrypt(token: str, key: bytes) -> str:
|
|
"""解不开就抛异常,绝不返回半截垃圾——那会变成查不出来的 401。"""
|
|
raw = str(token or "")
|
|
if not raw:
|
|
return ""
|
|
from Crypto.Cipher import AES
|
|
|
|
try:
|
|
blob = base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))
|
|
nonce, tag, body = blob[:12], blob[12:28], blob[28:]
|
|
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
|
|
return cipher.decrypt_and_verify(body, tag).decode("utf-8")
|
|
except Exception as exc:
|
|
raise SecretBoxError("模型密钥解密失败:主密钥不匹配或密文被改动") from exc
|
|
|
|
|
|
def masked(plaintext: str) -> str:
|
|
"""给界面看的遮罩。永远不回显完整密钥。"""
|
|
text = str(plaintext or "")
|
|
if not text:
|
|
return ""
|
|
if len(text) <= 8:
|
|
return "*" * len(text)
|
|
return f"{text[:4]}{'*' * 6}{text[-4:]}"
|