WechatOnCloud/bridge/woc_bridge/db/key_cache.py

346 lines
13 KiB
Python
Raw Permalink Normal View History

"""SQLCipher 密钥统一存储与持久化single source of truth
设计目标
- 统一密钥来源salt -> enc_key 映射默认 key已拒绝 salt来源/验证/pid 元信息
全部在此类管理DbReader server.py 都通过此类读写避免双份同步问题
- 持久化自动加载/保存 woc-keys.json容器重启后复用
- 线程安全内置 threading.Lock
- 进程失效检测pid / start_time 变化时 is_valid_for_pid 返回 False
使用方式
key_cache = KeyCache(keys_file="/config/woc-keys.json")
# 注入server.py / db_reader.py 都可调用)
key_cache.set_default_key(env_key, source="env", verified=False)
key_cache.set_key(key_hex, salt_hex=salt, source="api", verified=True)
key_cache.set_key_map(key_map, source="auto_extract", verified=True, pid=pid)
# 查询DbReader 用)
key = key_cache.get_key(salt_hex) # 严格按 salt回退到未拒绝的默认 key
has = key_cache.has_keys()
"""
from __future__ import annotations
import datetime
import json
import os
import threading
from typing import Any, Optional
class KeyCache:
"""DB 解密密钥统一存储。
所有密钥状态salt->key 映射默认 key已拒绝 salt来源/验证/pid
全部在此类管理DbReader server.py 都通过此类读写避免双份同步
"""
def __init__(
self,
keys_file: str = "/config/woc-keys.json",
) -> None:
"""
Args:
keys_file: 持久化密钥文件路径/config/woc-keys.json
"""
self.keys_file = keys_file
# 持久化的 salt -> enc_key 映射
self._salt_to_key: dict[str, str] = {}
# 默认 keyenv 注入或单 key 兼容),不持久化(未按 salt 验证)
self._default_key: Optional[str] = None
# 已验证默认 key 不匹配的 salt 集合,避免反复触发整库解密失败
self._rejected_default_salts: set[str] = set()
# 持久化文件中记录的 wxid
self._wxid: Optional[str] = None
# 元信息(不持久化,进程级状态)
self.source: Optional[str] = None # env / api / auto_extract
self.verified: bool = False
self.pid: Optional[int] = None
self.start_time: Optional[float] = None
self._lock = threading.Lock()
self._load_from_file()
# ------------------------------------------------------------------
# 文件持久化
# ------------------------------------------------------------------
def _load_from_file(self) -> None:
"""从 keys_file 加载密钥到内存。"""
if not os.path.isfile(self.keys_file):
return
try:
with open(self.keys_file, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
return
self._wxid = data.get("_wxid")
self._salt_to_key = {
k: v for k, v in data.items()
if not k.startswith("_") and isinstance(v, str)
}
# 恢复元信息:从文件加载的密钥在持久化前已通过验证
if self._salt_to_key:
self.source = "file"
self.verified = True
except (json.JSONDecodeError, OSError):
pass
def _save_to_file(self) -> None:
"""持久化 salt -> key 映射到 keys_file调用方需持锁"""
try:
os.makedirs(os.path.dirname(self.keys_file), exist_ok=True)
data: dict[str, Any] = {
"_wxid": self._wxid,
"_updated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
}
data.update(self._salt_to_key)
with open(self.keys_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
except OSError:
pass
def set_wxid(self, wxid: Optional[str]) -> None:
"""更新持久化文件中的 wxid 字段。"""
with self._lock:
self._wxid = wxid
self._save_to_file()
# ------------------------------------------------------------------
# 查询
# ------------------------------------------------------------------
def get_key(self, salt_hex: Optional[str] = None) -> Optional[str]:
"""返回指定 salt 的有效密钥。
严格按 salt 查找未命中时仅对未拒绝过的 salt 回退到默认 key
避免反复用错误默认 key 触发整库解密失败
"""
with self._lock:
if salt_hex:
if salt_hex in self._salt_to_key:
return self._salt_to_key[salt_hex]
if self._default_key and salt_hex not in self._rejected_default_salts:
return self._default_key
return None
return self._default_key
def get_default_key(self) -> Optional[str]:
"""返回默认密钥(向后兼容旧接口)。"""
with self._lock:
return self._default_key
def get_keys(self) -> dict[str, str]:
"""返回当前持久化的 salt -> key 映射副本。"""
with self._lock:
return dict(self._salt_to_key)
def has_keys(self) -> bool:
"""是否有可用的密钥(持久化或默认 key"""
with self._lock:
return bool(self._salt_to_key or self._default_key)
def has_persisted_keys(self) -> bool:
"""是否有持久化到文件的 salt -> key 映射。"""
with self._lock:
return bool(self._salt_to_key)
def key_prefix(self) -> Optional[str]:
"""返回缓存密钥的摘要(不泄露完整 key
salt 时返回数量 + 第一个 key 的前 4/ 4
"""
with self._lock:
if self._default_key:
k = self._default_key
return f"{k[:4]}...{k[-4:]}"
if not self._salt_to_key:
return None
first_key = next(iter(self._salt_to_key.values()))
prefix = f"{first_key[:4]}...{first_key[-4:]}"
if len(self._salt_to_key) > 1:
return f"{prefix} (+{len(self._salt_to_key) - 1} salts)"
return prefix
@property
def cached(self) -> bool:
"""是否有缓存的 key持久化或默认"""
with self._lock:
return self._default_key is not None or bool(self._salt_to_key)
def is_valid_for_pid(
self,
current_pid: Optional[int],
current_start_time: Optional[float] = None,
) -> bool:
"""检查缓存的 key 是否对当前进程仍有效。
pid 变化或 start_time 变化时返回 False需重新提取
"""
if not self.cached:
return False
with self._lock:
if current_pid is not None and self.pid is not None and current_pid != self.pid:
return False
if current_start_time is not None and self.start_time is not None and current_start_time != self.start_time:
return False
return True
# ------------------------------------------------------------------
# 注入
# ------------------------------------------------------------------
def set_key(
self,
key_hex: Optional[str],
salt_hex: Optional[str] = None,
source: Optional[str] = None,
verified: bool = True,
pid: Optional[int] = None,
start_time: Optional[float] = None,
) -> None:
"""设置单个密钥。
Args:
key_hex: 64 位十六进制密钥 None 时清空所有密钥与元信息
salt_hex: None 时设默认 key否则设 salt -> key 映射
source: env / api / auto_extract
verified: 是否已通过 HMAC 验证
pid / start_time: 自动提取时关联的进程信息
"""
with self._lock:
if key_hex is None:
# 清空所有
self._salt_to_key.clear()
self._default_key = None
self._rejected_default_salts.clear()
self.source = None
self.verified = False
self.pid = None
self.start_time = None
self._save_to_file()
return
if salt_hex is None:
self._default_key = key_hex
else:
self._salt_to_key[salt_hex] = key_hex
# 新 key 注入时清除该 salt 的拒绝标记
self._rejected_default_salts.discard(salt_hex)
self._save_to_file()
if source is not None:
self.source = source
self.verified = verified
if pid is not None:
self.pid = pid
if start_time is not None:
self.start_time = start_time
def set_default_key(
self,
key_hex: Optional[str],
source: Optional[str] = None,
verified: bool = False,
) -> None:
"""设置默认密钥env 注入专用),不影响 salt_to_key 映射。
set_key(salt_hex=None) 的区别
- set_key(salt_hex=None) 会清空 salt_to_key 并覆盖 woc-keys.json
- set_default_key 仅设 _default_key保留已持久化的多 salt 映射
用于 WOC_DB_KEY 环境变量加载容器重启时先从 woc-keys.json 恢复多 salt
映射再用 env key 作为默认兜底二者共存而非互斥
"""
with self._lock:
self._default_key = key_hex
if source is not None:
self.source = source
self.verified = verified
# 默认 key 不持久化到文件(未按 salt 验证,避免污染 woc-keys.json
def set_key_map(
self,
key_map: dict[str, str],
source: Optional[str] = None,
verified: bool = True,
pid: Optional[int] = None,
start_time: Optional[float] = None,
persist: bool = True,
) -> None:
"""批量设置 salt -> enc_key 映射。
Args:
key_map: {salt_hex: enc_key_hex}
source: env / api / auto_extract
verified: 是否已通过 HMAC 验证
pid / start_time: 自动提取时关联的进程信息
persist: 是否持久化到文件默认 True
"""
with self._lock:
self._salt_to_key = dict(key_map)
# 新 keymap 注入时清空拒绝标记(旧 salt 可能已重新匹配)
self._rejected_default_salts.clear()
if source is not None:
self.source = source
self.verified = verified
if pid is not None:
self.pid = pid
if start_time is not None:
self.start_time = start_time
if persist:
self._save_to_file()
def clear(self) -> None:
"""清空所有密钥与元信息(含持久化文件)。"""
with self._lock:
self._salt_to_key.clear()
self._default_key = None
self._rejected_default_salts.clear()
self.source = None
self.verified = False
self.pid = None
self.start_time = None
self._save_to_file()
def clear_default_key(self) -> None:
"""仅清空默认 key保留 salt_to_key 持久化映射)。
用于 env key 验证失败后清除默认 key不影响已持久化的多 salt 映射
"""
with self._lock:
self._default_key = None
self._rejected_default_salts.clear()
def mark_default_key_mismatch(self, salt_hex: str) -> None:
"""标记默认 key 不匹配该 salt。
DbReader._ensure_decrypted 在解密失败且使用的是默认 key 时调用
避免后续相同 salt 的查询反复触发整库解密
"""
with self._lock:
if self._default_key:
self._rejected_default_salts.add(salt_hex)
def set_meta(
self,
source: Optional[str] = None,
verified: Optional[bool] = None,
pid: Optional[int] = None,
start_time: Optional[float] = None,
) -> None:
"""更新元信息(不影响密钥存储)。
用于外部已通过其他途径设置 key 后补充元信息 source="api"
所有参数为 None 时不更新对应字段
"""
with self._lock:
if source is not None:
self.source = source
if verified is not None:
self.verified = verified
if pid is not None:
self.pid = pid
if start_time is not None:
self.start_time = start_time