174 lines
6.1 KiB
Python
174 lines
6.1 KiB
Python
"""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"]
|