Resume Matcher 如何加密存储各 LLM 提供方的 API Key?
Resume Matcher 如何加密存储各 LLM 提供方的 API Key【免费下载链接】Resume-MatcherThe #1 AI Harness for Building Resumes, PDFs, Cover Letters more, locally with 100 LLMs support.项目地址: https://gitcode.com/GitHub_Trending/re/Resume-MatcherResume Matcher 允许你为 OpenAI、Anthropic、Gemini 等多个 LLM 提供方分别配置 API Key。这些密钥不会以明文形式留在配置文件里它们在写入时经过 Fernet 对称加密密文按提供方存入 SQLite 的api_keys表对应的加密密钥保存在data/.secret_key文件中明文只在调用 LLM 时存在于内存中。本文基于仓库内的实现与测试文档说明这套加密存储的工作方式并给出核对密钥确实处于加密状态的验证方法。密钥存储在哪SQLiteapi_keys表而不是 config.jsonResume Matcher 的后端数据统一放在apps/backend/data/目录下。API Key 的落盘位置是 SQLite 数据库resume_matcher.db中的api_keys表表结构定义在 models.py 中class ApiKey(Base): An encrypted LLM provider API key. provider is the *key-store* provider name (e.g. google for the gemini LLM provider, via _PROVIDER_KEY_MAP). Only ciphertext is stored; plaintext exists in memory only at call time. __tablename__ api_keys provider: Mapped[str] mapped_column(String, primary_keyTrue) ciphertext: Mapped[str] mapped_column(Text) updated_at: Mapped[str] mapped_column(String, default_utcnow_iso)三点含义provider是密钥存储槽位名主键ciphertext只存密文注释明确说明只有密文落盘明文只存在于调用时的内存中。config.json则被明确排除在密钥存储之外。config.py 中的save_config_file在写盘前会剥掉api_keys与旧的单键api_key字段def save_config_file(config: dict[str, Any]) - None: Save non-secret configuration to config.json. Secrets (api_keys map and the legacy single api_key) are stripped before writing — they belong to the encrypted store only. config dict(config) config.pop(api_keys, None) config.pop(api_key, None) _write_config_json(config)架构文档 backend-architecture.md 也给出了同一结论API keys are never written toconfig.json。此外旧版接口PUT /config/llm-api-key已不再持久化任何密钥请求体中的api_key字段只用于响应掩码和向后兼容见 routers/config.py 中的注释。Fernet 密钥本身data/.secret_key加解密逻辑集中在 crypto.py使用的是cryptography库的 Fernet 对称加密requirements.txt 中固定为cryptography48.0.1。对称密钥即用于 Fernet 的 secret的文件路径为def _secret_path() - Path: return settings.data_dir / .secret_key即apps/backend/data/.secret_key。围绕这个文件实现里有几个值得注意的保护措施自动生成、权限收紧文件不存在时调用Fernet.generate_key()生成新密钥读取时会对已存在的文件重新执行os.chmod(path, 0o600)确保仅属主可读写。原子写入_write_secret先把完整密钥以 0600 权限写入同目录临时文件mkstempfsync后再原子地移动到目标位置并发读取方不会看到半截密钥。首次生成时使用exclusiveTrue原子硬链接目标已存在则抛FileExistsError保证多个进程竞争初始化时不会覆盖一个可能已经加密过数据的密钥。损坏自愈如果.secret_key内容损坏导致Fernet(key)构造失败代码会打 warning 并重新生成一个新密钥让保存/读取流程继续可用——注释同时说明此时previously stored ciphertext is already unrecoverable此前存储的密文已不可恢复。Fernet 实例按路径缓存_load_fernet()只在密钥路径变化时重新加载reset_cache()供测试将data_dir指向临时目录时使用。密钥的写入与读取路径对外接口在 routers/config.py 的/config/api-keys路由上支持的提供方槽位为openai、anthropic、google、openrouter、deepseek、groq、openai_compatible、ollama。注意gemini这个 LLM provider 映射到google密钥槽位。写入POST /api/v1/config/api-keys只更新请求中显式给出的提供方空字符串表示清除该提供方的密钥。落盘过程在 config.py 的save_api_keys_to_config中def save_api_keys_to_config(api_keys: dict[str, str]) - None: Replace the encrypted key store with api_keys (encrypting each). ... from app.crypto import encrypt from app.database import db # Encrypt everything first, then swap in a single transaction, so a partial # failure (encryption error or DB write) can never wipe previously stored # keys mid-replace. ciphertexts {provider: encrypt(key) for provider, key in api_keys.items() if key} db.replace_api_keys(ciphertexts)先全部加密、再在单个事务里替换的顺序保证了部分失败加密出错或数据库写入出错不会把已经存储的密钥清掉。读取get_api_keys_from_config()从api_keys表取出全部密文逐条decrypt解密失败的条目直接省略不返回、不抛错解密成功的明文只在内存中注入到配置字典供resolve_api_key解析后直接传给 LiteLLM 的api_key参数。查询与删除响应中密钥一律掩码只显示末 4 位# 查询所有提供方的密钥状态掩码 curl -s http://localhost:8000/api/v1/config/api-keys # 删除单个提供方的密钥 curl -s -X DELETE http://localhost:8000/api/v1/config/api-keys/openai # 清空全部密钥破坏性操作必须携带确认参数 curl -s -X DELETE http://localhost:8000/api/v1/config/api-keys?confirmCLEAR_ALL_KEYS端口 8000 为后端默认值settings.port。GET /api/v1/config/api-keys的返回结构按接口定义为{providers: [{provider, configured, masked_key}]}其中masked_key的格式是... 末4位长度 ≤4 时全为*。如何验证密钥确实处于加密状态项目自带的测试就是最直接的核对方式可以按路径阅读或运行tests/unit/test_crypto.py 覆盖加密原语本身往返一致性crypto.encrypt(sk-super-secret)得到的密文非空、不等于原文且crypto.decrypt能还原生成.secret_key文件且权限位为0o600空输入返回空字符串无效密文如not-a-valid-token解密返回而不是抛异常模拟密钥丢失换一个空的数据目录后旧密文解密结果为。tests/integration/test_config_api.py 中的TestEncryptedApiKeys覆盖接口层面的行为其中test_keys_encrypted_at_rest的断言逻辑是通过POST /api/v1/config/api-keys提交一个值该测试使用的示例值为sk-plaintext-123文档示例来自集成测试后直接从数据库中取出的ciphertexts[openai]不包含该明文而crypto.decrypt能还原出它test_responses_never_contain_raw_key则断言GET响应体中找不到原文、掩码值以末 4 位结尾。运行方式在apps/backend目录下pytest tests/unit/test_crypto.py如果你在本地实例上验证先用POST /api/v1/config/api-keys为某个提供方保存一个密钥再调用GET端点应看到该提供方configured: true且masked_key形如...abcd末 4 位同时检查data/config.json其中不应出现api_key或api_keys字段。失效行为与备份边界以下限制直接来自实现代码与测试迁移数据时需要知道secret 丢失或更换后密文不可恢复。crypto.py 的模块注释写明stored ciphertext is never recoverable without the original secret。当decrypt遇到InvalidToken时只打一条 warningFailed to decrypt a stored API key (secret rotated/lost?): ...并返回空字符串用户界面表现为需要重新输入密钥而不是崩溃。.secret_key与数据库必须在同一份数据里管理。密钥文件位于apps/backend/data/.secret_keySQLite 数据库位于同一data目录下的resume_matcher.db。只备份数据库而丢掉.secret_key恢复后所有密钥都会解密失败并按未配置处理两者都要一起保留该文件已被 gitignore不会进版本库。旧明文密钥的迁移后端启动时main.py 的 lifespan会执行migrate_legacy_keys()把旧版本config.json里遗留的明文api_keys/api_key折入加密存储——幂等且只在对应提供方槽位为空时写入随后从config.json中删除这些字段。这是从旧版升级时的自动收尾步骤无需手工操作。多引擎设计加密的api_keys表由独立的 sync 引擎服务db_engine.py因为它在同步的 LLM 调用热路径上被读取文档表则走 async 引擎。理解这一点对排查密钥读取问题有帮助但不影响日常操作。核对到以上各点——api_keys表只存密文、config.json无密钥字段、接口响应只有掩码、解密失败静默降级为空——即可确认 Resume Matcher 对各提供方 API Key 的加密存储链路完整生效。【免费下载链接】Resume-MatcherThe #1 AI Harness for Building Resumes, PDFs, Cover Letters more, locally with 100 LLMs support.项目地址: https://gitcode.com/GitHub_Trending/re/Resume-Matcher创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考