"""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"]