mindforge-memory 6.4.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- MindForge.py +20 -0
- mindforge/__init__.py +111 -0
- mindforge/adapters/__init__.py +18 -0
- mindforge/adapters/claude_adapter.py +84 -0
- mindforge/adapters/crewai_memory.py +76 -0
- mindforge/adapters/generic_api.py +183 -0
- mindforge/adapters/langgraph_store.py +463 -0
- mindforge/adapters/openclaw_adapter.py +91 -0
- mindforge/api/__init__.py +1 -0
- mindforge/api/server.py +1567 -0
- mindforge/cli/__init__.py +1 -0
- mindforge/cli/main.py +13607 -0
- mindforge/core/__init__.py +37 -0
- mindforge/core/embedding.py +847 -0
- mindforge/core/encryption.py +597 -0
- mindforge/core/indexer.py +363 -0
- mindforge/core/mindforge.py +6298 -0
- mindforge/core/paths.py +43 -0
- mindforge/core/query.py +501 -0
- mindforge/core/storage.py +18656 -0
- mindforge/core/types.py +314 -0
- mindforge/core/version.py +56 -0
- mindforge/daemon/__init__.py +0 -0
- mindforge/daemon/server.py +277 -0
- mindforge/mcp/__init__.py +4 -0
- mindforge/mcp/server.py +2559 -0
- mindforge/modules/__init__.py +138 -0
- mindforge/modules/categorizer.py +151 -0
- mindforge/modules/conflict_detector.py +356 -0
- mindforge/modules/connectors.py +331 -0
- mindforge/modules/context_window.py +337 -0
- mindforge/modules/event_bus.py +572 -0
- mindforge/modules/evolution.py +210 -0
- mindforge/modules/experience.py +402 -0
- mindforge/modules/federated.py +722 -0
- mindforge/modules/federated_acl.py +393 -0
- mindforge/modules/follow_up.py +315 -0
- mindforge/modules/hybrid_search.py +565 -0
- mindforge/modules/importer.py +374 -0
- mindforge/modules/integrator.py +196 -0
- mindforge/modules/intent_router.py +359 -0
- mindforge/modules/knowledge_graph.py +395 -0
- mindforge/modules/memory_compressor.py +592 -0
- mindforge/modules/memory_decay.py +474 -0
- mindforge/modules/memory_passport.py +250 -0
- mindforge/modules/multi_agent.py +534 -0
- mindforge/modules/multimodal.py +304 -0
- mindforge/modules/outcome_learning.py +186 -0
- mindforge/modules/personality.py +275 -0
- mindforge/modules/principled_forgetting.py +258 -0
- mindforge/modules/privacy.py +736 -0
- mindforge/modules/recall.py +263 -0
- mindforge/modules/session_focus.py +305 -0
- mindforge/modules/share_conflict.py +521 -0
- mindforge/modules/skill_extractor.py +340 -0
- mindforge/modules/skill_pack.py +205 -0
- mindforge/modules/timeline_viz.py +206 -0
- mindforge/modules/v6.py +931 -0
- mindforge_memory-6.4.1.dist-info/METADATA +281 -0
- mindforge_memory-6.4.1.dist-info/RECORD +64 -0
- mindforge_memory-6.4.1.dist-info/WHEEL +5 -0
- mindforge_memory-6.4.1.dist-info/entry_points.txt +3 -0
- mindforge_memory-6.4.1.dist-info/licenses/LICENSE +21 -0
- mindforge_memory-6.4.1.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MindForge v5.0 隐私引擎
|
|
3
|
+
隐私扫描、访问控制、合规报告
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
import sqlite3
|
|
12
|
+
import logging
|
|
13
|
+
import threading
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import List, Dict, Optional, Tuple, Any
|
|
16
|
+
|
|
17
|
+
from mindforge.core.storage import StorageEngine, MemoryEntry
|
|
18
|
+
from mindforge.core.types import PrivacyLevel
|
|
19
|
+
import base64
|
|
20
|
+
import secrets
|
|
21
|
+
import struct
|
|
22
|
+
import json
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class PrivacyScanResult:
|
|
29
|
+
"""隐私扫描结果"""
|
|
30
|
+
|
|
31
|
+
is_sensitive: bool = False
|
|
32
|
+
suggested_privacy: PrivacyLevel = PrivacyLevel.INTERNAL
|
|
33
|
+
confidence: float = 0.0
|
|
34
|
+
detected_types: List[str] = field(default_factory=list)
|
|
35
|
+
masked_preview: str = ""
|
|
36
|
+
details: Dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class AccessGrant:
|
|
41
|
+
"""访问授权"""
|
|
42
|
+
|
|
43
|
+
grant_id: str
|
|
44
|
+
memory_id: str
|
|
45
|
+
grantee: str
|
|
46
|
+
granted_by: str
|
|
47
|
+
granted_at: float
|
|
48
|
+
expires_at: Optional[float]
|
|
49
|
+
access_level: str = "read"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
SENSITIVE_PATTERNS = {
|
|
53
|
+
"phone": [r"1[3-9]\d{9}"],
|
|
54
|
+
"email": [r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"],
|
|
55
|
+
"id_card": [r"\d{17}[\dXx]"],
|
|
56
|
+
"bank_card": [r"\d{16,19}"],
|
|
57
|
+
"password": [r"(password|passwd|pwd|密码)\s*[:=]\s*\S+"],
|
|
58
|
+
"address": [r"(地址|住址|家|住)\s*[::]\s*.+"],
|
|
59
|
+
"name": [r"(姓名|名字)\s*[::]\s*.+"],
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
SENSITIVE_KEYWORDS = [
|
|
63
|
+
"密码",
|
|
64
|
+
"秘钥",
|
|
65
|
+
"密钥",
|
|
66
|
+
"token",
|
|
67
|
+
"secret",
|
|
68
|
+
"私密",
|
|
69
|
+
"隐私",
|
|
70
|
+
"身份证",
|
|
71
|
+
"银行卡",
|
|
72
|
+
"手机号",
|
|
73
|
+
"邮箱",
|
|
74
|
+
"地址",
|
|
75
|
+
"工资",
|
|
76
|
+
"收入",
|
|
77
|
+
"病历",
|
|
78
|
+
"健康",
|
|
79
|
+
"性",
|
|
80
|
+
"账号",
|
|
81
|
+
"口令",
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class PrivacyEngine:
|
|
86
|
+
"""隐私引擎"""
|
|
87
|
+
|
|
88
|
+
# v5.6.2 安全修复:2FA 验证会话有效期(5 分钟)
|
|
89
|
+
# P1-08 语义澄清:会话时间戳统一使用 time.monotonic()(见
|
|
90
|
+
# _verified_2fa_sessions),而非 time.time()。原因:monotonic 时钟单调递增、
|
|
91
|
+
# 不受系统墙上时钟回拨 / NTP 校正影响,攻击者无法通过调慢系统时间来延长
|
|
92
|
+
# 已授予的验证窗口。会话表仅存于内存,进程重启后自动清空(需重新验证),
|
|
93
|
+
# 因此不存在跨重启的 monotonic 基准漂移问题。
|
|
94
|
+
_2FA_VERIFY_WINDOW = 300 # 秒(按 time.monotonic() 计算的相对时长)
|
|
95
|
+
|
|
96
|
+
# v5.7.3 安全加固:2FA 连续失败阈值与锁定时长(防在线穷举)
|
|
97
|
+
_2FA_MAX_FAILURES = 5
|
|
98
|
+
_2FA_LOCK_SECONDS = 60
|
|
99
|
+
|
|
100
|
+
# v5.6.5 P1 #7:过期授权的后台清理节流间隔(秒)
|
|
101
|
+
_GRANT_PURGE_INTERVAL = 300
|
|
102
|
+
|
|
103
|
+
def __init__(self, storage: StorageEngine):
|
|
104
|
+
self.storage = storage
|
|
105
|
+
self._grants: Dict[str, List[AccessGrant]] = {}
|
|
106
|
+
# v5.6.5 P1 #7:保护 _grants 的线程锁 + 过期清理节流水位
|
|
107
|
+
self._grants_lock = threading.Lock()
|
|
108
|
+
self._last_grant_purge = 0.0
|
|
109
|
+
# v5.3.3 安全修复:二次验证令牌存储(替代始终返回 True 的漏洞)
|
|
110
|
+
# v5.6.2 安全修复:存储 token hash,用于验证码比对(不明文驻留)
|
|
111
|
+
self._second_factor_token_hashes: Dict[str, str] = {}
|
|
112
|
+
# v5.6.2 安全修复:记录已通过 2FA 验证的会话(actor -> 验证时间戳)
|
|
113
|
+
self._verified_2fa_sessions: Dict[str, float] = {}
|
|
114
|
+
# v5.7.3 安全加固:2FA 失败计数与锁定截止时间(仅内存,重启即失效)
|
|
115
|
+
self._2fa_fail_counts: Dict[str, int] = {}
|
|
116
|
+
self._2fa_locked_until: Dict[str, float] = {}
|
|
117
|
+
# v5.8.3:标准 TOTP(RFC 6238)密钥内存缓存(加密落库,见 totp_secrets 表)
|
|
118
|
+
self._totp_secrets: Dict[str, str] = {}
|
|
119
|
+
# v5.4.2 安全修复:持久化 grants 和 2FA tokens 到 SQLite,重启不丢失
|
|
120
|
+
self._init_persistence()
|
|
121
|
+
self._load_persisted_data()
|
|
122
|
+
|
|
123
|
+
def _init_persistence(self):
|
|
124
|
+
"""创建持久化表(如不存在)"""
|
|
125
|
+
try:
|
|
126
|
+
conn = self.storage._get_conn()
|
|
127
|
+
conn.execute("""
|
|
128
|
+
CREATE TABLE IF NOT EXISTS access_grants (
|
|
129
|
+
grant_id TEXT PRIMARY KEY,
|
|
130
|
+
memory_id TEXT NOT NULL,
|
|
131
|
+
grantee TEXT NOT NULL,
|
|
132
|
+
granted_by TEXT NOT NULL,
|
|
133
|
+
granted_at REAL NOT NULL,
|
|
134
|
+
expires_at REAL,
|
|
135
|
+
access_level TEXT DEFAULT 'read'
|
|
136
|
+
)
|
|
137
|
+
""")
|
|
138
|
+
conn.execute("""
|
|
139
|
+
CREATE TABLE IF NOT EXISTS second_factor_tokens (
|
|
140
|
+
actor TEXT PRIMARY KEY,
|
|
141
|
+
token_hash TEXT NOT NULL,
|
|
142
|
+
created_at REAL NOT NULL
|
|
143
|
+
)
|
|
144
|
+
""")
|
|
145
|
+
conn.commit()
|
|
146
|
+
except sqlite3.Error as e:
|
|
147
|
+
logger.error("隐私持久化表创建失败: %s", e)
|
|
148
|
+
|
|
149
|
+
def _load_persisted_data(self):
|
|
150
|
+
"""从 SQLite 加载 grants 和 2FA tokens"""
|
|
151
|
+
try:
|
|
152
|
+
conn = self.storage._get_conn()
|
|
153
|
+
# 加载 grants
|
|
154
|
+
rows = conn.execute(
|
|
155
|
+
"SELECT grant_id, memory_id, grantee, granted_by, granted_at, expires_at, access_level FROM access_grants"
|
|
156
|
+
).fetchall()
|
|
157
|
+
for row in rows:
|
|
158
|
+
grant = AccessGrant(
|
|
159
|
+
grant_id=row[0],
|
|
160
|
+
memory_id=row[1],
|
|
161
|
+
grantee=row[2],
|
|
162
|
+
granted_by=row[3],
|
|
163
|
+
granted_at=row[4],
|
|
164
|
+
expires_at=row[5],
|
|
165
|
+
access_level=row[6],
|
|
166
|
+
)
|
|
167
|
+
if row[1] not in self._grants:
|
|
168
|
+
self._grants[row[1]] = []
|
|
169
|
+
self._grants[row[1]].append(grant)
|
|
170
|
+
# v5.6.2 安全修复:加载 token_hash(而非空字符串),重启后验证码仍可验证
|
|
171
|
+
token_rows = conn.execute(
|
|
172
|
+
"SELECT actor, token_hash FROM second_factor_tokens"
|
|
173
|
+
).fetchall()
|
|
174
|
+
for row in token_rows:
|
|
175
|
+
self._second_factor_token_hashes[row[0]] = row[1]
|
|
176
|
+
except sqlite3.Error as e:
|
|
177
|
+
logger.error("隐私持久化数据加载失败: %s", e)
|
|
178
|
+
|
|
179
|
+
def scan(self, text: str) -> PrivacyScanResult:
|
|
180
|
+
"""扫描文本中的敏感信息"""
|
|
181
|
+
detected_types = []
|
|
182
|
+
max_sensitivity = 0
|
|
183
|
+
masked = text
|
|
184
|
+
|
|
185
|
+
for info_type, patterns in SENSITIVE_PATTERNS.items():
|
|
186
|
+
for pattern in patterns:
|
|
187
|
+
if re.search(pattern, text, re.IGNORECASE):
|
|
188
|
+
detected_types.append(info_type)
|
|
189
|
+
masked = re.sub(pattern, "[已脱敏]", masked, flags=re.IGNORECASE)
|
|
190
|
+
if info_type in ("password", "id_card", "bank_card"):
|
|
191
|
+
max_sensitivity = max(max_sensitivity, 3)
|
|
192
|
+
else:
|
|
193
|
+
max_sensitivity = max(max_sensitivity, 2)
|
|
194
|
+
break
|
|
195
|
+
|
|
196
|
+
keyword_count = sum(
|
|
197
|
+
1 for kw in SENSITIVE_KEYWORDS if kw.lower() in text.lower()
|
|
198
|
+
)
|
|
199
|
+
if keyword_count > 0:
|
|
200
|
+
max_sensitivity = max(max_sensitivity, 1)
|
|
201
|
+
if "敏感词" not in detected_types:
|
|
202
|
+
detected_types.append("敏感词")
|
|
203
|
+
|
|
204
|
+
is_sensitive = len(detected_types) > 0
|
|
205
|
+
|
|
206
|
+
if max_sensitivity >= 3:
|
|
207
|
+
suggested = PrivacyLevel.STRICT
|
|
208
|
+
elif max_sensitivity >= 2:
|
|
209
|
+
suggested = PrivacyLevel.PRIVATE
|
|
210
|
+
elif max_sensitivity >= 1:
|
|
211
|
+
suggested = PrivacyLevel.INTERNAL
|
|
212
|
+
else:
|
|
213
|
+
suggested = PrivacyLevel.PUBLIC
|
|
214
|
+
|
|
215
|
+
confidence = min(1.0, len(detected_types) * 0.3 + keyword_count * 0.1)
|
|
216
|
+
|
|
217
|
+
return PrivacyScanResult(
|
|
218
|
+
is_sensitive=is_sensitive,
|
|
219
|
+
suggested_privacy=suggested,
|
|
220
|
+
confidence=confidence,
|
|
221
|
+
detected_types=detected_types,
|
|
222
|
+
masked_preview=masked[:500],
|
|
223
|
+
details={
|
|
224
|
+
"keyword_count": keyword_count,
|
|
225
|
+
"sensitivity_score": max_sensitivity,
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def check_access(
|
|
230
|
+
self, entry: MemoryEntry, actor: str = "", session_id: str = ""
|
|
231
|
+
) -> Tuple[bool, str]:
|
|
232
|
+
"""检查访问权限"""
|
|
233
|
+
# v5.7.3 安全修复:保留原始 actor 判定"本机无身份调用"(CLI/单用户部署),
|
|
234
|
+
# 与远程显式自称 anonymous 相区分,避免无来源 INTERNAL 记忆被误拒或越权。
|
|
235
|
+
raw_actor = actor
|
|
236
|
+
if not actor:
|
|
237
|
+
actor = "anonymous"
|
|
238
|
+
|
|
239
|
+
if entry.privacy == PrivacyLevel.PUBLIC:
|
|
240
|
+
return True, "公开级记忆"
|
|
241
|
+
|
|
242
|
+
if entry.privacy == PrivacyLevel.INTERNAL:
|
|
243
|
+
# 本机进程内无身份调用(CLI / 单用户部署 / 进程内适配器未传 actor):
|
|
244
|
+
# 视同用户本人放行;一旦调用者显式携带身份(含 anonymous),
|
|
245
|
+
# 必须精确匹配来源,杜绝远程越权读取。
|
|
246
|
+
if not raw_actor and not session_id:
|
|
247
|
+
return True, "本机内部调用"
|
|
248
|
+
# 有来源:必须精确匹配,空值不参与匹配(杜绝越权读取)
|
|
249
|
+
if (entry.source_agent and entry.source_agent == actor) or (
|
|
250
|
+
entry.source_session and entry.source_session == session_id
|
|
251
|
+
):
|
|
252
|
+
return True, "同 Agent/会话"
|
|
253
|
+
return False, "内部级记忆仅同 Agent/会话可访问"
|
|
254
|
+
|
|
255
|
+
if entry.privacy == PrivacyLevel.PRIVATE:
|
|
256
|
+
if self._check_grant(entry.id, actor):
|
|
257
|
+
return True, "已授权访问"
|
|
258
|
+
if entry.source_agent == actor:
|
|
259
|
+
return True, "记忆所有者"
|
|
260
|
+
return False, "私密级记忆需要显式授权"
|
|
261
|
+
|
|
262
|
+
if entry.privacy == PrivacyLevel.STRICT:
|
|
263
|
+
if self._check_grant(entry.id, actor) and self._verify_second_factor(actor):
|
|
264
|
+
return True, "严格级授权访问"
|
|
265
|
+
return False, "严格级记忆需要二次验证"
|
|
266
|
+
|
|
267
|
+
return False, "未知隐私级别"
|
|
268
|
+
|
|
269
|
+
def grant_access(
|
|
270
|
+
self,
|
|
271
|
+
memory_id: str,
|
|
272
|
+
grantee: str,
|
|
273
|
+
granted_by: str = "",
|
|
274
|
+
duration_hours: Optional[float] = None,
|
|
275
|
+
access_level: str = "read",
|
|
276
|
+
) -> AccessGrant:
|
|
277
|
+
"""授予访问权限
|
|
278
|
+
|
|
279
|
+
P1 安全修复:验证调用者(granted_by)有权授权 — 必须是记忆所有者
|
|
280
|
+
或具有 admin 权限,防止任意用户给任意人授权 PRIVATE/STRICT 记忆。
|
|
281
|
+
"""
|
|
282
|
+
# 权限校验:granted_by 必须是所有者或有授权权限
|
|
283
|
+
if not granted_by:
|
|
284
|
+
raise PermissionError("授权需要调用者身份(granted_by 不能为空)")
|
|
285
|
+
|
|
286
|
+
if grantee == granted_by:
|
|
287
|
+
raise PermissionError("不能给自己授权")
|
|
288
|
+
|
|
289
|
+
is_owner = False
|
|
290
|
+
if self.storage:
|
|
291
|
+
entry = self.storage.get_memory(memory_id)
|
|
292
|
+
if entry is None:
|
|
293
|
+
raise ValueError(f"记忆不存在: {memory_id}")
|
|
294
|
+
# 检查是否为所有者(source_agent 或 metadata 中的 owner 字段)
|
|
295
|
+
entry_owner = getattr(entry, "source_agent", "") or ""
|
|
296
|
+
meta_owner = (
|
|
297
|
+
(entry.metadata or {}).get("owner", "")
|
|
298
|
+
if hasattr(entry, "metadata")
|
|
299
|
+
else ""
|
|
300
|
+
)
|
|
301
|
+
is_owner = (granted_by == entry_owner) or (granted_by == meta_owner)
|
|
302
|
+
|
|
303
|
+
# 非所有者拒绝授权(admin 角色可在未来扩展)
|
|
304
|
+
if not is_owner:
|
|
305
|
+
raise PermissionError(f"用户 {granted_by} 无权授权记忆 {memory_id}")
|
|
306
|
+
|
|
307
|
+
grant = AccessGrant(
|
|
308
|
+
grant_id=str(uuid.uuid4()),
|
|
309
|
+
memory_id=memory_id,
|
|
310
|
+
grantee=grantee,
|
|
311
|
+
granted_by=granted_by,
|
|
312
|
+
granted_at=time.time(),
|
|
313
|
+
expires_at=time.time() + duration_hours * 3600 if duration_hours else None,
|
|
314
|
+
access_level=access_level,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
self._maybe_purge_expired_grants()
|
|
318
|
+
with self._grants_lock:
|
|
319
|
+
if memory_id not in self._grants:
|
|
320
|
+
self._grants[memory_id] = []
|
|
321
|
+
self._grants[memory_id].append(grant)
|
|
322
|
+
|
|
323
|
+
# v5.4.2:持久化到 SQLite
|
|
324
|
+
try:
|
|
325
|
+
conn = self.storage._get_conn()
|
|
326
|
+
conn.execute(
|
|
327
|
+
"INSERT OR REPLACE INTO access_grants (grant_id, memory_id, grantee, granted_by, granted_at, expires_at, access_level) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
328
|
+
(
|
|
329
|
+
grant.grant_id,
|
|
330
|
+
grant.memory_id,
|
|
331
|
+
grant.grantee,
|
|
332
|
+
grant.granted_by,
|
|
333
|
+
grant.granted_at,
|
|
334
|
+
grant.expires_at,
|
|
335
|
+
grant.access_level,
|
|
336
|
+
),
|
|
337
|
+
)
|
|
338
|
+
conn.commit()
|
|
339
|
+
except sqlite3.Error as e:
|
|
340
|
+
logger.error("授权持久化失败: %s", e)
|
|
341
|
+
|
|
342
|
+
return grant
|
|
343
|
+
|
|
344
|
+
def revoke_access(self, memory_id: str, grantee: str) -> bool:
|
|
345
|
+
"""撤销访问权限"""
|
|
346
|
+
if memory_id not in self._grants:
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
with self._grants_lock:
|
|
350
|
+
self._grants[memory_id] = [
|
|
351
|
+
g for g in self._grants[memory_id] if g.grantee != grantee
|
|
352
|
+
]
|
|
353
|
+
# v5.4.2:同步删除持久化记录
|
|
354
|
+
try:
|
|
355
|
+
conn = self.storage._get_conn()
|
|
356
|
+
conn.execute(
|
|
357
|
+
"DELETE FROM access_grants WHERE memory_id = ? AND grantee = ?",
|
|
358
|
+
(memory_id, grantee),
|
|
359
|
+
)
|
|
360
|
+
conn.commit()
|
|
361
|
+
except sqlite3.Error as e:
|
|
362
|
+
logger.error("撤销授权持久化失败: %s", e)
|
|
363
|
+
return True
|
|
364
|
+
|
|
365
|
+
def purge_expired_grants(self) -> int:
|
|
366
|
+
"""删除已过期授权(内存字典 + SQLite 持久层),返回删除条数。
|
|
367
|
+
|
|
368
|
+
v5.6.5 P1 #7:此前 _check_grant 只在检查时跳过过期 grant,过期条目
|
|
369
|
+
永久驻留 _grants 与 access_grants 表,长期运行持续膨胀。
|
|
370
|
+
"""
|
|
371
|
+
now = time.time()
|
|
372
|
+
removed = 0
|
|
373
|
+
with self._grants_lock:
|
|
374
|
+
for mid in list(self._grants.keys()):
|
|
375
|
+
alive = [
|
|
376
|
+
g
|
|
377
|
+
for g in self._grants[mid]
|
|
378
|
+
if not (g.expires_at is not None and g.expires_at < now)
|
|
379
|
+
]
|
|
380
|
+
removed += len(self._grants[mid]) - len(alive)
|
|
381
|
+
if alive:
|
|
382
|
+
self._grants[mid] = alive
|
|
383
|
+
else:
|
|
384
|
+
self._grants.pop(mid, None)
|
|
385
|
+
if removed:
|
|
386
|
+
try:
|
|
387
|
+
conn = self.storage._get_conn()
|
|
388
|
+
conn.execute(
|
|
389
|
+
"DELETE FROM access_grants "
|
|
390
|
+
"WHERE expires_at IS NOT NULL AND expires_at < ?",
|
|
391
|
+
(now,),
|
|
392
|
+
)
|
|
393
|
+
conn.commit()
|
|
394
|
+
except sqlite3.Error as e:
|
|
395
|
+
logger.error("清理过期授权持久化失败: %s", e)
|
|
396
|
+
return removed
|
|
397
|
+
|
|
398
|
+
def _maybe_purge_expired_grants(self) -> None:
|
|
399
|
+
"""节流触发过期授权清理,失败不影响主流程"""
|
|
400
|
+
now = time.time()
|
|
401
|
+
if now - self._last_grant_purge < self._GRANT_PURGE_INTERVAL:
|
|
402
|
+
return
|
|
403
|
+
self._last_grant_purge = now
|
|
404
|
+
try:
|
|
405
|
+
self.purge_expired_grants()
|
|
406
|
+
except Exception as e:
|
|
407
|
+
logger.error("purge_expired_grants failed: %s", e)
|
|
408
|
+
|
|
409
|
+
def _check_grant(self, memory_id: str, actor: str) -> bool:
|
|
410
|
+
"""检查授权(v5.6.5 P1 #7:节流清理 + 遍历加锁)"""
|
|
411
|
+
self._maybe_purge_expired_grants()
|
|
412
|
+
now = time.time()
|
|
413
|
+
with self._grants_lock:
|
|
414
|
+
grants = list(self._grants.get(memory_id, []))
|
|
415
|
+
for grant in grants:
|
|
416
|
+
if grant.grantee == actor:
|
|
417
|
+
if grant.expires_at is not None and grant.expires_at < now:
|
|
418
|
+
continue
|
|
419
|
+
return True
|
|
420
|
+
return False
|
|
421
|
+
|
|
422
|
+
def _verify_second_factor(self, actor: str) -> bool:
|
|
423
|
+
"""二次验证(v5.6.2 安全修复:基于会话的验证,而非仅检查注册)
|
|
424
|
+
|
|
425
|
+
STRICT 级别记忆需要二次验证。此前该方法仅检查 token 是否存在(truthy),
|
|
426
|
+
导致只要注册过 2FA 就直接放行,形同虚设。
|
|
427
|
+
|
|
428
|
+
v5.6.2 修复:
|
|
429
|
+
- 检查 actor 是否在验证会话窗口内(默认 5 分钟)
|
|
430
|
+
- 未注册 2FA 的 actor 一律拒绝
|
|
431
|
+
- 注册过但未验证的 actor 也拒绝(需先调用 verify_second_factor_with_code)
|
|
432
|
+
- 验证会话过期后需重新验证
|
|
433
|
+
"""
|
|
434
|
+
if not actor:
|
|
435
|
+
return False
|
|
436
|
+
# 检查是否已注册 2FA
|
|
437
|
+
if actor not in self._second_factor_token_hashes:
|
|
438
|
+
return False
|
|
439
|
+
# 检查是否在验证会话有效期内(时间基准为 time.monotonic())
|
|
440
|
+
# 缺省 0 表示「从未验证」:monotonic() 读数恒远大于窗口,故被正确判为过期。
|
|
441
|
+
verified_at = self._verified_2fa_sessions.get(actor, 0)
|
|
442
|
+
if time.monotonic() - verified_at > self._2FA_VERIFY_WINDOW:
|
|
443
|
+
return False
|
|
444
|
+
return True
|
|
445
|
+
|
|
446
|
+
def register_second_factor(self, actor: str, token: str) -> bool:
|
|
447
|
+
"""注册二次验证令牌(v5.3.3 新增)
|
|
448
|
+
|
|
449
|
+
v5.4.2 安全加固:令牌以 SHA-256 hash 存储,不明文持久化。
|
|
450
|
+
v5.6.2 安全加固:内存中也只存 hash,不明文驻留。
|
|
451
|
+
|
|
452
|
+
.. deprecated:: v5.8.3
|
|
453
|
+
本入口为同值哈希校验(静态口令语义):若上层把长期共享密钥
|
|
454
|
+
当作 code 传入,2FA 会退化为静态口令。请改用标准 TOTP
|
|
455
|
+
(RFC 6238)::meth:`register_totp_secret` + :meth:`verify_totp_code`。
|
|
456
|
+
本方法仅保留兼容,新代码不得使用。
|
|
457
|
+
|
|
458
|
+
Args:
|
|
459
|
+
actor: 需要二次验证的用户/Agent
|
|
460
|
+
token: 验证令牌(如 TOTP 密钥、一次性密码)
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
是否注册成功
|
|
464
|
+
"""
|
|
465
|
+
if not actor or not token:
|
|
466
|
+
return False
|
|
467
|
+
logger.warning(
|
|
468
|
+
"register_second_factor 已弃用(静态口令语义,P3-1);"
|
|
469
|
+
"请改用 register_totp_secret + verify_totp_code(RFC 6238)"
|
|
470
|
+
)
|
|
471
|
+
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
472
|
+
self._second_factor_token_hashes[actor] = token_hash
|
|
473
|
+
# v5.4.2:持久化 hash 到 SQLite(不明文存储)
|
|
474
|
+
try:
|
|
475
|
+
conn = self.storage._get_conn()
|
|
476
|
+
conn.execute(
|
|
477
|
+
"INSERT OR REPLACE INTO second_factor_tokens (actor, token_hash, created_at) VALUES (?, ?, ?)",
|
|
478
|
+
(actor, token_hash, time.time()),
|
|
479
|
+
)
|
|
480
|
+
conn.commit()
|
|
481
|
+
except sqlite3.Error as e:
|
|
482
|
+
logger.error("2FA 令牌持久化失败: %s", e)
|
|
483
|
+
return True
|
|
484
|
+
|
|
485
|
+
def verify_second_factor_with_code(self, actor: str, code: str) -> bool:
|
|
486
|
+
"""使用验证码进行二次验证(v5.3.3 新增)
|
|
487
|
+
|
|
488
|
+
v5.6.2 安全修复:
|
|
489
|
+
- 使用 hash 比对而非明文比对
|
|
490
|
+
- 验证通过后记录到验证会话,_verify_second_factor 在窗口内放行
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
actor: 用户/Agent ID
|
|
494
|
+
code: 验证码
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
验证是否通过
|
|
498
|
+
"""
|
|
499
|
+
if not actor or not code:
|
|
500
|
+
return False
|
|
501
|
+
now = time.monotonic()
|
|
502
|
+
# v5.7.3 安全加固:锁定期内直接拒绝,避免在线穷举验证码
|
|
503
|
+
locked_until = self._2fa_locked_until.get(actor, 0.0)
|
|
504
|
+
if now < locked_until:
|
|
505
|
+
return False
|
|
506
|
+
token_hash = self._second_factor_token_hashes.get(actor)
|
|
507
|
+
if not token_hash:
|
|
508
|
+
return False
|
|
509
|
+
# 使用 hmac.compare_digest 防止时序攻击(比较 hash 值)
|
|
510
|
+
code_hash = hashlib.sha256(code.encode()).hexdigest()
|
|
511
|
+
ok = hmac.compare_digest(token_hash, code_hash)
|
|
512
|
+
if ok:
|
|
513
|
+
# 验证通过:清零失败计数,记录会话(time.monotonic,仅存内存,重启即失效)
|
|
514
|
+
self._2fa_fail_counts.pop(actor, None)
|
|
515
|
+
self._verified_2fa_sessions[actor] = now
|
|
516
|
+
return True
|
|
517
|
+
|
|
518
|
+
# 验证失败:累计计数,达到阈值触发锁定
|
|
519
|
+
failures = self._2fa_fail_counts.get(actor, 0) + 1
|
|
520
|
+
self._2fa_fail_counts[actor] = failures
|
|
521
|
+
if failures >= self._2FA_MAX_FAILURES:
|
|
522
|
+
self._2fa_locked_until[actor] = now + self._2FA_LOCK_SECONDS
|
|
523
|
+
self._2fa_fail_counts.pop(actor, None)
|
|
524
|
+
logger.warning(
|
|
525
|
+
"2FA 验证连续失败 %d 次,锁定 actor=%s 共 %.0f 秒",
|
|
526
|
+
failures,
|
|
527
|
+
actor,
|
|
528
|
+
self._2FA_LOCK_SECONDS,
|
|
529
|
+
)
|
|
530
|
+
return False
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
# ===================== v5.8.3 标准 TOTP(RFC 6238)=====================
|
|
535
|
+
def register_totp_secret(self, actor: str,
|
|
536
|
+
secret_b32: Optional[str] = None) -> Optional[str]:
|
|
537
|
+
"""注册标准 TOTP 共享密钥(v5.8.3,RFC 6238)。
|
|
538
|
+
|
|
539
|
+
修复 P3-1:此前 register_second_factor 存的是 sha256(token),验证时
|
|
540
|
+
对传入 code 做同值哈希比较——若上层把长期共享密钥当 code 传,2FA
|
|
541
|
+
即退化为静态口令。本方法按 RFC 6238 语义:注册只存 base32 密钥,
|
|
542
|
+
验证对时间派生码(verify_totp_code)比较。
|
|
543
|
+
|
|
544
|
+
Args:
|
|
545
|
+
actor: 用户/Agent ID
|
|
546
|
+
secret_b32: 可选 base32 密钥;缺省生成随机 20 字节密钥
|
|
547
|
+
|
|
548
|
+
Returns:
|
|
549
|
+
base32 密钥(用于配置 Authenticator),失败返回 None
|
|
550
|
+
"""
|
|
551
|
+
if not actor:
|
|
552
|
+
return None
|
|
553
|
+
if secret_b32 is None:
|
|
554
|
+
secret_b32 = base64.b32encode(secrets.token_bytes(20)).decode()
|
|
555
|
+
secret_b32 = secret_b32.upper().strip()
|
|
556
|
+
try:
|
|
557
|
+
base64.b32decode(secret_b32, casefold=True)
|
|
558
|
+
except Exception:
|
|
559
|
+
return None
|
|
560
|
+
blob = None
|
|
561
|
+
if self.storage.encryption is not None:
|
|
562
|
+
try:
|
|
563
|
+
blob = self.storage.encryption.encrypt(secret_b32)
|
|
564
|
+
except Exception:
|
|
565
|
+
blob = None
|
|
566
|
+
self._totp_secrets[actor] = secret_b32
|
|
567
|
+
if blob is not None:
|
|
568
|
+
try:
|
|
569
|
+
conn = self.storage._get_conn()
|
|
570
|
+
conn.execute(
|
|
571
|
+
"CREATE TABLE IF NOT EXISTS totp_secrets ("
|
|
572
|
+
"actor TEXT PRIMARY KEY, secret_enc TEXT NOT NULL, "
|
|
573
|
+
"created_at REAL)")
|
|
574
|
+
conn.execute(
|
|
575
|
+
"INSERT OR REPLACE INTO totp_secrets "
|
|
576
|
+
"(actor, secret_enc, created_at) VALUES (?, ?, ?)",
|
|
577
|
+
(actor, json.dumps(blob.to_dict()), time.time()))
|
|
578
|
+
conn.commit()
|
|
579
|
+
except sqlite3.Error as e:
|
|
580
|
+
logger.error("TOTP 密钥持久化失败: %s", e)
|
|
581
|
+
return secret_b32
|
|
582
|
+
|
|
583
|
+
def verify_totp_code(self, actor: str, code: str,
|
|
584
|
+
window: int = 1) -> bool:
|
|
585
|
+
"""验证 TOTP 6 位码(v5.8.3,RFC 6238:HMAC-SHA1,30s 步长 ±window)。
|
|
586
|
+
|
|
587
|
+
复用 2FA 会话窗口 / 失败锁定机制(与 verify_second_factor_with_code 一致)。
|
|
588
|
+
"""
|
|
589
|
+
if not actor or not code:
|
|
590
|
+
return False
|
|
591
|
+
now = time.monotonic()
|
|
592
|
+
locked_until = self._2fa_locked_until.get(actor, 0.0)
|
|
593
|
+
if now < locked_until:
|
|
594
|
+
return False
|
|
595
|
+
secret = self._totp_secrets.get(actor)
|
|
596
|
+
if secret is None:
|
|
597
|
+
secret = self._load_totp_secret(actor)
|
|
598
|
+
if secret is None:
|
|
599
|
+
return False
|
|
600
|
+
if self._verify_totp(secret, code, window):
|
|
601
|
+
self._2fa_fail_counts.pop(actor, None)
|
|
602
|
+
self._verified_2fa_sessions[actor] = now
|
|
603
|
+
return True
|
|
604
|
+
failures = self._2fa_fail_counts.get(actor, 0) + 1
|
|
605
|
+
self._2fa_fail_counts[actor] = failures
|
|
606
|
+
if failures >= self._2FA_MAX_FAILURES:
|
|
607
|
+
self._2fa_locked_until[actor] = now + self._2FA_LOCK_SECONDS
|
|
608
|
+
self._2fa_fail_counts.pop(actor, None)
|
|
609
|
+
return False
|
|
610
|
+
|
|
611
|
+
@staticmethod
|
|
612
|
+
def _totp_value(secret_b32: str, at_time: float, step: int = 30,
|
|
613
|
+
digits: int = 6) -> str:
|
|
614
|
+
"""RFC 6238 TOTP 派生码:HMAC-SHA1(secret, counter) 动态截断取 6 位。"""
|
|
615
|
+
counter = int(at_time // step)
|
|
616
|
+
msg = struct.pack(">Q", counter)
|
|
617
|
+
digest = hmac.new(base64.b32decode(secret_b32, casefold=True), msg,
|
|
618
|
+
hashlib.sha1).digest()
|
|
619
|
+
offset = digest[-1] & 0x0F
|
|
620
|
+
code = (struct.unpack(">I", digest[offset:offset + 4])[0]
|
|
621
|
+
& 0x7FFFFFFF) % (10 ** digits)
|
|
622
|
+
return str(code).zfill(digits)
|
|
623
|
+
|
|
624
|
+
def _verify_totp(self, secret_b32: str, code: str, window: int) -> bool:
|
|
625
|
+
now = time.time()
|
|
626
|
+
for w in range(-window, window + 1):
|
|
627
|
+
if hmac.compare_digest(
|
|
628
|
+
self._totp_value(secret_b32, now + w * 30), str(code)):
|
|
629
|
+
return True
|
|
630
|
+
return False
|
|
631
|
+
|
|
632
|
+
def _load_totp_secret(self, actor: str) -> Optional[str]:
|
|
633
|
+
"""从 SQLite 读取并解密 TOTP 密钥(v5.8.3)。"""
|
|
634
|
+
try:
|
|
635
|
+
conn = self.storage._get_conn()
|
|
636
|
+
row = conn.execute(
|
|
637
|
+
"SELECT secret_enc FROM totp_secrets WHERE actor = ?",
|
|
638
|
+
(actor,)).fetchone()
|
|
639
|
+
except sqlite3.Error:
|
|
640
|
+
return None
|
|
641
|
+
if row is None or not row[0] or self.storage.encryption is None:
|
|
642
|
+
return None
|
|
643
|
+
try:
|
|
644
|
+
from mindforge.core.encryption import EncryptedBlob
|
|
645
|
+
blob = EncryptedBlob.from_dict(json.loads(row[0]))
|
|
646
|
+
secret = self.storage.encryption.decrypt(blob)
|
|
647
|
+
self._totp_secrets[actor] = secret
|
|
648
|
+
return secret
|
|
649
|
+
except Exception as e:
|
|
650
|
+
logger.error("TOTP 密钥解密失败: %s", e)
|
|
651
|
+
return None
|
|
652
|
+
|
|
653
|
+
def generate_compliance_report(self) -> dict:
|
|
654
|
+
"""生成合规报告"""
|
|
655
|
+
import time
|
|
656
|
+
|
|
657
|
+
stats = self.storage.get_stats()
|
|
658
|
+
|
|
659
|
+
total = stats.get("total", 0)
|
|
660
|
+
by_privacy = stats.get("by_privacy", {})
|
|
661
|
+
|
|
662
|
+
audit_log = self.storage.get_audit_log(limit=1000)
|
|
663
|
+
access_count = sum(1 for a in audit_log if a.action == "access")
|
|
664
|
+
denied_count = 0
|
|
665
|
+
|
|
666
|
+
private_count = by_privacy.get("PRIVATE", 0) + by_privacy.get("STRICT", 0)
|
|
667
|
+
|
|
668
|
+
return {
|
|
669
|
+
"report_time": time.time(),
|
|
670
|
+
"total_memories": total,
|
|
671
|
+
"private_memories": private_count,
|
|
672
|
+
"strict_memories": by_privacy.get("STRICT", 0),
|
|
673
|
+
"public_memories": by_privacy.get("PUBLIC", 0),
|
|
674
|
+
"active_grants": sum(len(g) for g in self._grants.values()),
|
|
675
|
+
"total_access_events": access_count,
|
|
676
|
+
"compliance_status": "PASS" if private_count == 0 else "REVIEW",
|
|
677
|
+
"by_privacy": by_privacy,
|
|
678
|
+
"encryption_enabled": self.storage.encrypted,
|
|
679
|
+
"audit_log_entries": len(audit_log),
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
def export_with_privacy(
|
|
683
|
+
self, entries: List[MemoryEntry], anonymize: bool = False
|
|
684
|
+
) -> List[dict]:
|
|
685
|
+
"""带隐私保护的导出"""
|
|
686
|
+
result = []
|
|
687
|
+
for entry in entries:
|
|
688
|
+
data = entry.to_dict()
|
|
689
|
+
|
|
690
|
+
if entry.privacy in (PrivacyLevel.PRIVATE, PrivacyLevel.STRICT):
|
|
691
|
+
if anonymize:
|
|
692
|
+
scan = self.scan(entry.content)
|
|
693
|
+
data["content"] = scan.masked_preview
|
|
694
|
+
data["anonymized"] = True
|
|
695
|
+
else:
|
|
696
|
+
data["content"] = "[已加密 - 需要授权解密]"
|
|
697
|
+
data["encrypted_export"] = True
|
|
698
|
+
|
|
699
|
+
result.append(data)
|
|
700
|
+
|
|
701
|
+
return result
|
|
702
|
+
|
|
703
|
+
def data_profiling(self) -> dict:
|
|
704
|
+
"""数据画像(隐私风险评估)"""
|
|
705
|
+
all_memories = self.storage.list_memories(limit=1000)
|
|
706
|
+
|
|
707
|
+
risk_scores = []
|
|
708
|
+
categories_risk = {}
|
|
709
|
+
|
|
710
|
+
for entry in all_memories:
|
|
711
|
+
content = entry.content
|
|
712
|
+
if entry.encrypted:
|
|
713
|
+
try:
|
|
714
|
+
content = self.storage.decrypt_content(entry)
|
|
715
|
+
except (ValueError, TypeError):
|
|
716
|
+
content = ""
|
|
717
|
+
|
|
718
|
+
scan = self.scan(content)
|
|
719
|
+
risk_scores.append(scan.confidence)
|
|
720
|
+
|
|
721
|
+
if scan.is_sensitive:
|
|
722
|
+
cat = entry.category
|
|
723
|
+
categories_risk[cat] = categories_risk.get(cat, 0) + 1
|
|
724
|
+
|
|
725
|
+
avg_risk = sum(risk_scores) / len(risk_scores) if risk_scores else 0
|
|
726
|
+
|
|
727
|
+
return {
|
|
728
|
+
"total_scanned": len(all_memories),
|
|
729
|
+
"average_risk_score": avg_risk,
|
|
730
|
+
"high_risk_count": sum(1 for s in risk_scores if s > 0.7),
|
|
731
|
+
"medium_risk_count": sum(1 for s in risk_scores if 0.3 < s <= 0.7),
|
|
732
|
+
"low_risk_count": sum(1 for s in risk_scores if s <= 0.3),
|
|
733
|
+
"high_risk_categories": sorted(
|
|
734
|
+
categories_risk.items(), key=lambda x: x[1], reverse=True
|
|
735
|
+
)[:5],
|
|
736
|
+
}
|