Files

174 lines
6.1 KiB
Python
Raw Permalink Normal View History

"""Provider 密钥安全存储(交付报告 P0-1)。
设计:
- 优先使用 macOS 钥匙串(security 命令,无需第三方依赖);
- 钥匙串不可用时自动降级回 SQLite 明文(与旧行为一致),保证跨平台可运行;
- 数据库 api_key 字段只存引用(keychain:service:key-name)或掩码,
真实密钥按需取回,日志和 API 响应只出现掩码。
"""
from __future__ import annotations
import subprocess
import sys
import os
import shutil
import stat
import re
from .config import DATA_DIR
from .env_config import IS_DEV
# 开发钥匙串必须显式开启;默认保持隔离且不触碰生产 namespace。
DEV_KEYCHAIN_ENABLED = os.environ.get("YAWAN_DEV_KEYCHAIN", "").strip().lower() in {"1", "true", "yes"}
KEYCHAIN_SERVICE = "yawen-workbench.providers.dev" if IS_DEV else "yawen-workbench.providers"
# 密钥文件兜底目录(非 macOS 且希望不落库时的中间态;当前主路径是 DB 明文降级)
KEYFILE_DIR = DATA_DIR / "secrets"
KEYFILE_PREFIX = "keyfile:"
_SAFE_KEY_RE = re.compile(r"[^A-Za-z0-9_.-]+")
_IS_MAC = sys.platform == "darwin"
_security = shutil.which("security") if _IS_MAC else None
def _keychain_name(provider_key: str) -> str:
return f"{KEYCHAIN_SERVICE}:{provider_key}"
def _keyfile_path(provider_key: str) -> Path:
safe = _SAFE_KEY_RE.sub("_", str(provider_key or "")).strip("._")
if not safe:
raise ValueError("密钥引用名称无效")
return KEYFILE_DIR / f"{safe}.key"
def _store_keyfile(provider_key: str, secret: str) -> str:
path = _keyfile_path(provider_key)
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
try:
path.chmod(stat.S_IREAD | stat.S_IWRITE)
except OSError:
pass
path.write_text(secret, encoding="utf-8")
try:
# POSIX: owner-only read/write. Windows Python maps only the read-only
# bit, but keeping the secret under the app data directory avoids plaintext
# in SQLite and is sufficient for this local development fallback.
path.chmod(stat.S_IREAD | stat.S_IWRITE)
except OSError:
pass
return f"{KEYFILE_PREFIX}{KEYCHAIN_SERVICE}:{provider_key}"
def _resolve_keyfile(db_value: str, provider_key: str = "") -> str:
prefix = KEYFILE_PREFIX
if not str(db_value or "").startswith(prefix):
return ""
reference = str(db_value)[len(prefix):]
expected = f"{KEYCHAIN_SERVICE}:"
if not reference.startswith(expected):
return ""
ref_key = reference[len(expected):]
key_name = provider_key or ref_key
if not key_name or key_name != ref_key:
return ""
path = _keyfile_path(key_name)
try:
return path.read_text(encoding="utf-8").strip()
except OSError:
return ""
def _delete_keyfile(provider_key: str) -> None:
try:
path = _keyfile_path(provider_key)
if path.exists():
try:
path.chmod(stat.S_IREAD | stat.S_IWRITE)
except OSError:
pass
path.unlink()
except (OSError, ValueError):
pass
def keychain_available() -> bool:
return bool(_security) and (not IS_DEV or DEV_KEYCHAIN_ENABLED)
def store_secret(provider_key: str, secret: str) -> str:
"""存储密钥,返回应写进 DB 的引用字符串。失败时返回原密钥(明文降级)。"""
if not secret:
return ""
if keychain_available():
name = _keychain_name(provider_key)
# 先删旧值再写入(security 不支持原子覆盖)
subprocess.run(
[_security, "delete-generic-password", "-s", name],
capture_output=True, timeout=10,
)
result = subprocess.run(
[_security, "add-generic-password", "-s", name, "-a", "yawen",
"-w", secret, "-U"],
capture_output=True, timeout=10,
)
if result.returncode == 0:
return f"keychain:{KEYCHAIN_SERVICE}:{provider_key}"
if IS_DEV:
# Windows/Linux local development does not have the macOS `security`
# CLI. Store the value in a local app-data file and keep only a
# `keyfile:` reference in SQLite, instead of failing provider setup.
return _store_keyfile(provider_key, secret)
return secret
def resolve_secret(db_value: str, provider_key: str = "") -> str:
"""把 DB 里的值解析成真实密钥:引用 → 钥匙串读取;其他原样返回。"""
value = str(db_value or "")
if value.startswith(KEYFILE_PREFIX):
return _resolve_keyfile(value, provider_key)
if IS_DEV and value.startswith("keychain:") and not value.startswith("keychain:yawen-workbench.providers.dev:"):
return ""
if IS_DEV and not value.startswith("keychain:") and not DEV_KEYCHAIN_ENABLED:
return ""
if value.startswith("keychain:") and keychain_available():
prefix = "keychain:"
reference = value[len(prefix):]
expected = f"{KEYCHAIN_SERVICE}:"
if not reference.startswith(expected):
return ""
ref_key = reference[len(expected):]
name = _keychain_name(provider_key or ref_key)
result = subprocess.run(
[_security, "find-generic-password", "-s", name, "-w"],
capture_output=True, text=True, timeout=10,
)
if result.returncode == 0:
return result.stdout.strip()
return ""
return "" if value.startswith("keychain:") else value
def delete_secret(provider_key: str) -> None:
_delete_keyfile(provider_key)
if keychain_available():
subprocess.run(
[_security, "delete-generic-password", "-s", _keychain_name(provider_key)],
capture_output=True, timeout=10,
)
def mask_secret(value: str) -> str:
value = str(value or "")
if value.startswith("keychain:"):
return "•••• 钥匙串保管"
if value.startswith(KEYFILE_PREFIX):
return "•••• 本机密钥文件"
if len(value) <= 4:
return "••••" if value else ""
return "***" + value[-4:]
__all__ = ["keychain_available", "store_secret", "resolve_secret", "delete_secret", "mask_secret"]