feat(agents): Phase3.2 Agent凭据Fernet加密存储 写入加密+调用点解密+返回脱敏+主密钥持久化防竞态 [v2026.07.19.7]
CI / gate (push) Has been cancelled
CI / gate (push) Has been cancelled
This commit is contained in:
1 parent
0600c37b9e
commit
3b34ba78b8
6 files changed
+1073
-946
No files matched your search
@@ -0,0 +1,105 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Phase 3.2 - Agent 凭据加解密 (Fernet 对称加密)。
|
||||
|
||||
主密钥获取策略 (防竞态):
|
||||
- 优先环境变量 AGENT_MASTER_KEY。
|
||||
- 否则读写 /app/secrets/agent_master_key:
|
||||
* 部署阶段由 patch 显式生成并落盘 (原子写), 容器重启/提交后稳定存在。
|
||||
* 运行时只读, 文件存在即读取, 绝不重新生成 —— 避免多进程/多请求并发
|
||||
重建密钥导致新旧密文无法互解。
|
||||
* 仅当文件确实不存在时, 才在文件锁保护下生成 (双重检查, 原子替换)。
|
||||
- 存储格式: 密文以 "enc:" 前缀标识; 非 "enc:" 视为旧明文 (向后兼容, 解密原样返回)。
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
import fcntl
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MASTER_KEY_FILE = "/app/secrets/agent_master_key"
|
||||
_fernet_instance = None
|
||||
|
||||
|
||||
def _generate_and_persist_key() -> bytes:
|
||||
key = Fernet.generate_key()
|
||||
os.makedirs(os.path.dirname(_MASTER_KEY_FILE), exist_ok=True)
|
||||
tmp = _MASTER_KEY_FILE + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
f.write(key.decode())
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
try:
|
||||
os.chmod(tmp, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
os.replace(tmp, _MASTER_KEY_FILE) # 原子替换, 避免读到半截文件
|
||||
return key
|
||||
|
||||
|
||||
def get_master_key() -> bytes:
|
||||
env_key = os.environ.get("AGENT_MASTER_KEY")
|
||||
if env_key:
|
||||
return env_key.encode() if isinstance(env_key, str) else env_key
|
||||
# 运行时只读: 文件已存在则直接读取 (部署阶段已生成)
|
||||
if os.path.exists(_MASTER_KEY_FILE):
|
||||
try:
|
||||
with open(_MASTER_KEY_FILE, "r") as f:
|
||||
k = f.read().strip()
|
||||
if k:
|
||||
return k.encode()
|
||||
except Exception as e:
|
||||
logger.warning("[credential] 读取主密钥失败: %s" % e)
|
||||
# 文件缺失: 加锁串行生成, 防多进程并发重建
|
||||
lockf = open(_MASTER_KEY_FILE + ".lock", "w")
|
||||
try:
|
||||
fcntl.flock(lockf, fcntl.LOCK_EX)
|
||||
if os.path.exists(_MASTER_KEY_FILE): # 双重检查
|
||||
with open(_MASTER_KEY_FILE, "r") as f:
|
||||
k = f.read().strip()
|
||||
if k:
|
||||
return k.encode()
|
||||
return _generate_and_persist_key()
|
||||
finally:
|
||||
fcntl.flock(lockf, fcntl.LOCK_UN)
|
||||
try:
|
||||
lockf.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
global _fernet_instance
|
||||
if _fernet_instance is None:
|
||||
_fernet_instance = Fernet(get_master_key())
|
||||
return _fernet_instance
|
||||
|
||||
|
||||
def encrypt_secret(plain):
|
||||
if plain is None or plain == "":
|
||||
return None
|
||||
if isinstance(plain, str) and plain.startswith("enc:"):
|
||||
return plain # 已是密文, 幂等
|
||||
try:
|
||||
return "enc:" + _fernet().encrypt(plain.encode()).decode()
|
||||
except Exception as e:
|
||||
logger.error("[credential] 加密失败: %s" % e)
|
||||
return None
|
||||
|
||||
|
||||
def decrypt_secret(cipher):
|
||||
if cipher is None or cipher == "":
|
||||
return None
|
||||
if isinstance(cipher, str) and cipher.startswith("enc:"):
|
||||
try:
|
||||
return _fernet().decrypt(cipher[4:].encode()).decode()
|
||||
except Exception as e:
|
||||
logger.error("[credential] 解密失败(密钥不匹配或数据损坏): %s" % e)
|
||||
return None
|
||||
return cipher # 旧明文, 向后兼容
|
||||
|
||||
|
||||
def mask_secret(plain):
|
||||
if not plain:
|
||||
return ""
|
||||
return ("*" * 8) + plain[-4:]
|
||||
Reference in new issue
Block a user