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.
Files changed (64) hide show
  1. MindForge.py +20 -0
  2. mindforge/__init__.py +111 -0
  3. mindforge/adapters/__init__.py +18 -0
  4. mindforge/adapters/claude_adapter.py +84 -0
  5. mindforge/adapters/crewai_memory.py +76 -0
  6. mindforge/adapters/generic_api.py +183 -0
  7. mindforge/adapters/langgraph_store.py +463 -0
  8. mindforge/adapters/openclaw_adapter.py +91 -0
  9. mindforge/api/__init__.py +1 -0
  10. mindforge/api/server.py +1567 -0
  11. mindforge/cli/__init__.py +1 -0
  12. mindforge/cli/main.py +13607 -0
  13. mindforge/core/__init__.py +37 -0
  14. mindforge/core/embedding.py +847 -0
  15. mindforge/core/encryption.py +597 -0
  16. mindforge/core/indexer.py +363 -0
  17. mindforge/core/mindforge.py +6298 -0
  18. mindforge/core/paths.py +43 -0
  19. mindforge/core/query.py +501 -0
  20. mindforge/core/storage.py +18656 -0
  21. mindforge/core/types.py +314 -0
  22. mindforge/core/version.py +56 -0
  23. mindforge/daemon/__init__.py +0 -0
  24. mindforge/daemon/server.py +277 -0
  25. mindforge/mcp/__init__.py +4 -0
  26. mindforge/mcp/server.py +2559 -0
  27. mindforge/modules/__init__.py +138 -0
  28. mindforge/modules/categorizer.py +151 -0
  29. mindforge/modules/conflict_detector.py +356 -0
  30. mindforge/modules/connectors.py +331 -0
  31. mindforge/modules/context_window.py +337 -0
  32. mindforge/modules/event_bus.py +572 -0
  33. mindforge/modules/evolution.py +210 -0
  34. mindforge/modules/experience.py +402 -0
  35. mindforge/modules/federated.py +722 -0
  36. mindforge/modules/federated_acl.py +393 -0
  37. mindforge/modules/follow_up.py +315 -0
  38. mindforge/modules/hybrid_search.py +565 -0
  39. mindforge/modules/importer.py +374 -0
  40. mindforge/modules/integrator.py +196 -0
  41. mindforge/modules/intent_router.py +359 -0
  42. mindforge/modules/knowledge_graph.py +395 -0
  43. mindforge/modules/memory_compressor.py +592 -0
  44. mindforge/modules/memory_decay.py +474 -0
  45. mindforge/modules/memory_passport.py +250 -0
  46. mindforge/modules/multi_agent.py +534 -0
  47. mindforge/modules/multimodal.py +304 -0
  48. mindforge/modules/outcome_learning.py +186 -0
  49. mindforge/modules/personality.py +275 -0
  50. mindforge/modules/principled_forgetting.py +258 -0
  51. mindforge/modules/privacy.py +736 -0
  52. mindforge/modules/recall.py +263 -0
  53. mindforge/modules/session_focus.py +305 -0
  54. mindforge/modules/share_conflict.py +521 -0
  55. mindforge/modules/skill_extractor.py +340 -0
  56. mindforge/modules/skill_pack.py +205 -0
  57. mindforge/modules/timeline_viz.py +206 -0
  58. mindforge/modules/v6.py +931 -0
  59. mindforge_memory-6.4.1.dist-info/METADATA +281 -0
  60. mindforge_memory-6.4.1.dist-info/RECORD +64 -0
  61. mindforge_memory-6.4.1.dist-info/WHEEL +5 -0
  62. mindforge_memory-6.4.1.dist-info/entry_points.txt +3 -0
  63. mindforge_memory-6.4.1.dist-info/licenses/LICENSE +21 -0
  64. 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
+ }