@furongjun1999/dsh-memory 0.5.0 → 0.5.1
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.
- package/README.md +552 -465
- package/docs/README.md +1 -0
- package/docs/eval/bench_lingshu_self/bench_self.py +140 -0
- package/docs/eval/bench_lingshu_self/self_bench_result.json +404 -0
- package/docs/eval/bench_lingshu_self//347/201/265/346/236/242/350/207/252/345/272/223/347/253/257/345/210/260/347/253/257/346/243/200/347/264/242/345/256/236/346/265/213_v1.0.md +38 -0
- package/docs/eval//344/270/215/345/217/257/351/235/240/346/200/247/350/220/275/345/234/260_P0_v1.0.md +185 -0
- package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +422 -0
- package/docs/eval//347/253/257/345/210/260/347/253/257LoCoMoQA/345/220/214/345/217/243/345/276/204/345/257/271/347/205/247_v1.0.md +100 -0
- package/docs/eval//347/253/257/345/210/260/347/253/257/345/271/262/346/211/260/346/261/240/350/257/204/346/265/213_/347/241/256/345/256/232/346/200/247/350/243/201/345/206/263vsLLM_judge_v1.1.md +197 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v1.md +156 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v10.md +210 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v11.md +227 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v12.md +203 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v13.md +233 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v14.md +191 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v15.md +213 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v16.md +214 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v17.md +199 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v2.md +156 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v3.md +152 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v4.md +128 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v5.md +114 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v6.md +192 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v7.md +187 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v8.md +207 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v9.md +203 -0
- package/docs/hive//345/256/211/345/205/250/345/256/241/350/256/241/345/256/236/351/224/232_v0.1.md +202 -0
- package/docs/hive//350/234/202/345/267/242/345/217/214/345/256/236/344/276/213/344/272/222/351/252/214_/350/256/276/350/256/241/345/256/232/347/250/277.md +19 -3
- package/docs/images/lingshu-moonlight-covenant-poster-preview.jpg +0 -0
- package/docs/images/lingshu-moonlight-covenant-poster.png +0 -0
- package/docs/mdcg//345/212/237/350/203/275/350/260/203/347/224/250/346/230/240/345/260/204/350/241/250_v0.1.md +40 -40
- package/docs/theory//344/270/215/345/217/257/351/235/240/345/256/232/347/220/206/344/270/216/345/244/261/346/225/210/344/274/230/345/205/210/346/241/206/346/236/266_v0.3.md +365 -0
- package/docs//344/270/215/345/217/257/351/235/240/346/200/247/347/220/206/350/256/272_v0.1.md +343 -0
- package/lib/bridge.d.ts +9 -0
- package/lib/bridge.js +35 -0
- package/lib/index.js +7 -1
- package/lib/lib/roleplay_web.js +530 -443
- package/lib/lib/token_store.d.ts +7 -1
- package/lib/lib/token_store.js +12 -3
- package/md_cg/audit.py +12 -1
- package/md_cg/backfill.py +16 -15
- package/md_cg/backfill_bucket_zh.py +35 -0
- package/md_cg/bench_e2e_judge.py +532 -0
- package/md_cg/bench_e2e_locomo_qa.py +368 -0
- package/md_cg/bench_e2e_qa.py +256 -0
- package/md_cg/branches.py +18 -2
- package/md_cg/ccgc.py +3 -2
- package/md_cg/chain.py +19 -4
- package/md_cg/consolidate.py +7 -6
- package/md_cg/crosscheck.py +4 -3
- package/md_cg/crypto.py +439 -437
- package/md_cg/datapath.py +395 -335
- package/md_cg/evidence.py +582 -580
- package/md_cg/export.py +3 -1
- package/md_cg/forgetting.py +2 -2
- package/md_cg/fsutil.py +48 -0
- package/md_cg/hotcache.py +255 -238
- package/md_cg/interop.py +161 -22
- package/md_cg/judgment_manifest.py +177 -0
- package/md_cg/links.py +655 -622
- package/md_cg/mcp_server.py +280 -29
- package/md_cg/mdcg.py +616 -125
- package/md_cg/mdcos.py +430 -66
- package/md_cg/mreview/govern.py +5 -4
- package/md_cg/postings.py +4 -2
- package/md_cg/readcache.py +76 -18
- package/md_cg/reconcile.py +228 -0
- package/md_cg/review_cli.py +170 -0
- package/md_cg/routing.py +28 -0
- package/md_cg/run_tests.py +211 -0
- package/md_cg/scrub.py +862 -852
- package/md_cg/security.py +385 -275
- package/md_cg/selfreport.py +3 -2
- package/md_cg/signer.py +565 -562
- package/md_cg/sources.py +3 -2
- package/md_cg/stg.py +6 -0
- package/md_cg/sustain.py +1168 -1138
- package/md_cg/test_access_hints.py +147 -0
- package/md_cg/test_branch_discard_tombstone.py +136 -0
- package/md_cg/test_branches.py +259 -249
- package/md_cg/test_chain_read_isolate.py +168 -0
- package/md_cg/test_datapath_device_name.py +203 -0
- package/md_cg/test_emit_negtail_cache.py +156 -0
- package/md_cg/test_en_pipeline.py +186 -166
- package/md_cg/test_govern_directread.py +421 -0
- package/md_cg/test_i32_hotcache_env_key.py +218 -0
- package/md_cg/test_identity_attribution.py +228 -147
- package/md_cg/test_index_durability.py +238 -224
- package/md_cg/test_interop.py +4 -2
- package/md_cg/test_interop_judgment.py +228 -0
- package/md_cg/test_issue39_utf8_stdio.py +273 -0
- package/md_cg/test_links_concurrent_write.py +188 -0
- package/md_cg/test_merge_upsert.py +168 -0
- package/md_cg/test_n123_derive_expiry_chain.py +205 -0
- package/md_cg/test_n130_verify_falsified_protect.py +185 -0
- package/md_cg/test_n131_merge_gate.py +205 -0
- package/md_cg/test_p1x_ref_root.py +160 -0
- package/md_cg/test_p27_docindex.py +774 -765
- package/md_cg/test_p2_mcp.py +3 -0
- package/md_cg/test_p32_backfill.py +304 -298
- package/md_cg/test_p39_verify_flow.py +90 -50
- package/md_cg/test_p47_session_view.py +60 -25
- package/md_cg/test_propose_tail_index.py +157 -0
- package/md_cg/test_read_scope_b27.py +277 -0
- package/md_cg/test_readcache_default_on.py +168 -0
- package/md_cg/test_readcache_precise_inval.py +270 -0
- package/md_cg/test_readcache_prodpath.py +55 -7
- package/md_cg/test_reconcile_v0.py +294 -0
- package/md_cg/test_retr_s1.py +6 -2
- package/md_cg/test_retr_s1b.py +67 -0
- package/md_cg/test_retr_s7.py +8 -0
- package/md_cg/test_retr_s9_entity_ctx.py +181 -175
- package/md_cg/test_retr_score_once.py +208 -0
- package/md_cg/test_review_onepass.py +170 -0
- package/md_cg/test_rrf_graph_seed_cache.py +154 -0
- package/md_cg/test_security_audit.py +155 -0
- package/md_cg/test_security_audit_b26.py +161 -0
- package/md_cg/test_security_audit_v21.py +250 -0
- package/md_cg/test_semantic_canonical.py +255 -241
- package/md_cg/test_session_isolation.py +168 -0
- package/md_cg/test_snapshot_autoclose.py +187 -0
- package/md_cg/test_tail_watermark_race.py +208 -0
- package/md_cg/test_tenant_env_override_warn.py +139 -0
- package/md_cg/test_tenant_registry_corrupt_warn.py +151 -0
- package/md_cg/test_v14_fixes.py +415 -397
- package/md_cg/test_verify_dirty_reconcile.py +157 -0
- package/md_cg/theory.py +276 -273
- package/md_cg/tokens.py +734 -677
- package/md_cg/units.py +3 -2
- package/md_cg/vision_evidence.py +4 -3
- package/md_cg/whitebox_kb/wisdom/code_solidified.json +6195 -6195
- package/md_cg/whitebox_kb/wisdom/multilang_ir.py +128 -124
- package/md_cg/writepipe.py +554 -550
- package/package.json +6 -2
- package/src/bridge.ts +434 -401
- package/src/index.ts +526 -518
- package/src/lib/roleplay_web.ts +1019 -932
- package/src/lib/token_store.ts +202 -192
package/md_cg/crypto.py
CHANGED
|
@@ -1,438 +1,440 @@
|
|
|
1
|
-
# -*- coding: utf-8 -*-
|
|
2
|
-
"""用户私有内容的端到端加密(at-rest)——密钥即访问权 + 身份一致性识别。
|
|
3
|
-
|
|
4
|
-
设计边界(诚实标注)
|
|
5
|
-
--------------------
|
|
6
|
-
· **加密范围**:仅 `sensitivity >= private` 的节点**正文**(private / secret)。
|
|
7
|
-
public / internal 保持明文——它们是可公开知识,加密只增加成本。
|
|
8
|
-
· **元数据明文**:frontmatter(layer / tags / condition_space / importance /
|
|
9
|
-
sensitivity)不加密。原因:条件路由与召回依赖元数据,加密它等于让记忆 OS
|
|
10
|
-
失能。代价:元数据本身可被读出——这是**明确取舍**,不是疏漏。
|
|
11
|
-
· **密文不参与全文索引**:正文加密后,明文查询词不会命中密文。因此无密钥者
|
|
12
|
-
只能走元数据路(知道「存在一条 private 记忆」,读不到内容)。
|
|
13
|
-
· **威胁模型**:防磁盘 / 备份 / 仓库泄露、防无密钥者读取。
|
|
14
|
-
**不防**本地内存取证与侧信道(纯 Python 实现的固有限制,不虚报)。
|
|
15
|
-
|
|
16
|
-
密钥层级
|
|
17
|
-
--------
|
|
18
|
-
KEK(主密钥,仓库外)── wrap ──> DEK(每租户数据密钥,存 _keys.json)
|
|
19
|
-
│
|
|
20
|
-
└── AEAD 加密 ──> 节点正文
|
|
21
|
-
|
|
22
|
-
· KEK 来源(按优先级):
|
|
23
|
-
① 环境变量 `MDCG_MASTER_KEY`(64 位 hex 或 base64)
|
|
24
|
-
② 主密钥文件 `~/.mdcg/master.key`(首次自动生成,0600)
|
|
25
|
-
· KEK 不落仓库;DEK 被 KEK 包裹后存租户根目录,单独拿走 `_keys.json` 无法解密。
|
|
26
|
-
· **身份一致性识别**:DEK 信封绑定 `(tenant, actor, clearance)`,且节点密文把
|
|
27
|
-
`(node_id, tenant, actor)` 作为 AEAD 的 AAD。因此
|
|
28
|
-
① 换了身份(actor / tenant 不符)→ 解不开;
|
|
29
|
-
② 把密文拷贝到另一个节点 → 校验失败。
|
|
30
|
-
即「密钥 + 身份」双因子,缺一不可。
|
|
31
|
-
|
|
32
|
-
密码学实现
|
|
33
|
-
----------
|
|
34
|
-
ChaCha20-Poly1305(RFC 8439)**纯标准库实现**(对齐 D-005「核心零外部依赖」)。
|
|
35
|
-
正确性由 RFC 8439 §2.8.2 官方测试向量验证(见 `test_p13_encryption.py`)。
|
|
36
|
-
"""
|
|
37
|
-
from __future__ import annotations
|
|
38
|
-
|
|
39
|
-
import base64
|
|
40
|
-
import hashlib
|
|
41
|
-
import hmac
|
|
42
|
-
import json
|
|
43
|
-
import os
|
|
44
|
-
import secrets
|
|
45
|
-
import struct
|
|
46
|
-
import time
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
#
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
s[
|
|
93
|
-
s[
|
|
94
|
-
s[
|
|
95
|
-
s[
|
|
96
|
-
s[
|
|
97
|
-
s[
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
+ list(struct.unpack("<
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
_quarter_round(w,
|
|
113
|
-
_quarter_round(w,
|
|
114
|
-
_quarter_round(w,
|
|
115
|
-
_quarter_round(w,
|
|
116
|
-
_quarter_round(w,
|
|
117
|
-
_quarter_round(w,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
#
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if len(
|
|
164
|
-
raise CryptoError(f"
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if len(
|
|
176
|
-
raise CryptoError(f"
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
#
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
#
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
#
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
"
|
|
307
|
-
"
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
if env
|
|
324
|
-
raise LockedError("
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
#
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
#
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
rec
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
#
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
"
|
|
418
|
-
"
|
|
419
|
-
"
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
"
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
"
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
"
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
"
|
|
436
|
-
|
|
437
|
-
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""用户私有内容的端到端加密(at-rest)——密钥即访问权 + 身份一致性识别。
|
|
3
|
+
|
|
4
|
+
设计边界(诚实标注)
|
|
5
|
+
--------------------
|
|
6
|
+
· **加密范围**:仅 `sensitivity >= private` 的节点**正文**(private / secret)。
|
|
7
|
+
public / internal 保持明文——它们是可公开知识,加密只增加成本。
|
|
8
|
+
· **元数据明文**:frontmatter(layer / tags / condition_space / importance /
|
|
9
|
+
sensitivity)不加密。原因:条件路由与召回依赖元数据,加密它等于让记忆 OS
|
|
10
|
+
失能。代价:元数据本身可被读出——这是**明确取舍**,不是疏漏。
|
|
11
|
+
· **密文不参与全文索引**:正文加密后,明文查询词不会命中密文。因此无密钥者
|
|
12
|
+
只能走元数据路(知道「存在一条 private 记忆」,读不到内容)。
|
|
13
|
+
· **威胁模型**:防磁盘 / 备份 / 仓库泄露、防无密钥者读取。
|
|
14
|
+
**不防**本地内存取证与侧信道(纯 Python 实现的固有限制,不虚报)。
|
|
15
|
+
|
|
16
|
+
密钥层级
|
|
17
|
+
--------
|
|
18
|
+
KEK(主密钥,仓库外)── wrap ──> DEK(每租户数据密钥,存 _keys.json)
|
|
19
|
+
│
|
|
20
|
+
└── AEAD 加密 ──> 节点正文
|
|
21
|
+
|
|
22
|
+
· KEK 来源(按优先级):
|
|
23
|
+
① 环境变量 `MDCG_MASTER_KEY`(64 位 hex 或 base64)
|
|
24
|
+
② 主密钥文件 `~/.mdcg/master.key`(首次自动生成,0600)
|
|
25
|
+
· KEK 不落仓库;DEK 被 KEK 包裹后存租户根目录,单独拿走 `_keys.json` 无法解密。
|
|
26
|
+
· **身份一致性识别**:DEK 信封绑定 `(tenant, actor, clearance)`,且节点密文把
|
|
27
|
+
`(node_id, tenant, actor)` 作为 AEAD 的 AAD。因此
|
|
28
|
+
① 换了身份(actor / tenant 不符)→ 解不开;
|
|
29
|
+
② 把密文拷贝到另一个节点 → 校验失败。
|
|
30
|
+
即「密钥 + 身份」双因子,缺一不可。
|
|
31
|
+
|
|
32
|
+
密码学实现
|
|
33
|
+
----------
|
|
34
|
+
ChaCha20-Poly1305(RFC 8439)**纯标准库实现**(对齐 D-005「核心零外部依赖」)。
|
|
35
|
+
正确性由 RFC 8439 §2.8.2 官方测试向量验证(见 `test_p13_encryption.py`)。
|
|
36
|
+
"""
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import base64
|
|
40
|
+
import hashlib
|
|
41
|
+
import hmac
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import secrets
|
|
45
|
+
import struct
|
|
46
|
+
import time
|
|
47
|
+
|
|
48
|
+
from .datapath import aux_root
|
|
49
|
+
|
|
50
|
+
# ---- 常量 ----------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
ENVELOPE_VERSION = 1
|
|
53
|
+
ALG = "chacha20poly1305"
|
|
54
|
+
NONCE_LEN = 12
|
|
55
|
+
KEY_LEN = 32
|
|
56
|
+
TAG_LEN = 16
|
|
57
|
+
|
|
58
|
+
ENC_PREFIX = "<!-- mdcg-enc:v1:"
|
|
59
|
+
ENC_SUFFIX = " -->"
|
|
60
|
+
|
|
61
|
+
# 需要加密的密级(用户私有内容)
|
|
62
|
+
ENCRYPTED_LEVELS = ("private", "secret")
|
|
63
|
+
|
|
64
|
+
KEYS_FILE = "_keys.json"
|
|
65
|
+
AUDIT_FILE = "_crypto.jsonl"
|
|
66
|
+
MASTER_ENV = "MDCG_MASTER_KEY"
|
|
67
|
+
MASTER_FILE = os.path.join(aux_root(), "master.key")
|
|
68
|
+
|
|
69
|
+
# scrypt 参数(交互式场景:N=2^14 / r=8 / p=1,约 16MB 内存)
|
|
70
|
+
SCRYPT_N, SCRYPT_R, SCRYPT_P, SCRYPT_DKLEN = 2 ** 14, 8, 1, 32
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# 生效条件:不适用(无必需形参与模块级常量)
|
|
74
|
+
class CryptoError(Exception):
|
|
75
|
+
"""加解密 / 密钥相关错误。"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# 生效条件:不适用(无必需形参与模块级常量)
|
|
79
|
+
class LockedError(CryptoError):
|
|
80
|
+
"""无密钥或身份不符——内容不可读(fail-closed,绝不降级为明文)。"""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---- ChaCha20(RFC 8439 §2.3)--------------------------------------------
|
|
84
|
+
|
|
85
|
+
# 生效条件:x、n 为入参,返回 ((x << n) & 0xFFFFFFFF) | (x >> (32 - n));源码未校验 x、n 类型或范围。
|
|
86
|
+
def _rotl32(x, n):
|
|
87
|
+
return ((x << n) & 0xFFFFFFFF) | (x >> (32 - n))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# 生效条件:s、a、b、c、d 为入参,依次读写 s[a]、s[b]、s[c]、s[d] 并按源码顺序做加法、异或、_rotl32 更新;源码未校验 s 元素类型或索引范围。
|
|
91
|
+
def _quarter_round(s, a, b, c, d):
|
|
92
|
+
s[a] = (s[a] + s[b]) & 0xFFFFFFFF
|
|
93
|
+
s[d] = _rotl32(s[d] ^ s[a], 16)
|
|
94
|
+
s[c] = (s[c] + s[d]) & 0xFFFFFFFF
|
|
95
|
+
s[b] = _rotl32(s[b] ^ s[c], 12)
|
|
96
|
+
s[a] = (s[a] + s[b]) & 0xFFFFFFFF
|
|
97
|
+
s[d] = _rotl32(s[d] ^ s[a], 8)
|
|
98
|
+
s[c] = (s[c] + s[d]) & 0xFFFFFFFF
|
|
99
|
+
s[b] = _rotl32(s[b] ^ s[c], 7)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# 生效条件:key、counter、nonce 为入参,按 const + key 解包 + counter 低 32 位 + nonce 解包构造 state,执行 10 次双轮后返回 16 个 32 位小端打包的 64 字节块。
|
|
103
|
+
def _chacha_block(key, counter, nonce):
|
|
104
|
+
"""生成 64 字节密钥流块。"""
|
|
105
|
+
const = b"expand 32-byte k"
|
|
106
|
+
st = (list(struct.unpack("<4I", const))
|
|
107
|
+
+ list(struct.unpack("<8I", key))
|
|
108
|
+
+ [counter & 0xFFFFFFFF]
|
|
109
|
+
+ list(struct.unpack("<3I", nonce)))
|
|
110
|
+
w = list(st)
|
|
111
|
+
for _ in range(10): # 20 轮 = 10 次双轮
|
|
112
|
+
_quarter_round(w, 0, 4, 8, 12)
|
|
113
|
+
_quarter_round(w, 1, 5, 9, 13)
|
|
114
|
+
_quarter_round(w, 2, 6, 10, 14)
|
|
115
|
+
_quarter_round(w, 3, 7, 11, 15)
|
|
116
|
+
_quarter_round(w, 0, 5, 10, 15)
|
|
117
|
+
_quarter_round(w, 1, 6, 11, 12)
|
|
118
|
+
_quarter_round(w, 2, 7, 8, 13)
|
|
119
|
+
_quarter_round(w, 3, 4, 9, 14)
|
|
120
|
+
return struct.pack("<16I", *[(w[i] + st[i]) & 0xFFFFFFFF for i in range(16)])
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# 生效条件:key、counter、nonce、data 为入参,对 data 从 0 到 len(data) 步长 64 分块,每块用 _chacha_block(key, counter + i//64, nonce) 生成密钥流并逐字节异或,返回等长 bytes;data 为空时返回 b""。
|
|
124
|
+
def _chacha20_xor(key, counter, nonce, data):
|
|
125
|
+
out = bytearray(len(data))
|
|
126
|
+
for i in range(0, len(data), 64):
|
|
127
|
+
ks = _chacha_block(key, counter + i // 64, nonce)
|
|
128
|
+
for j, b in enumerate(data[i:i + 64]):
|
|
129
|
+
out[i + j] = b ^ ks[j]
|
|
130
|
+
return bytes(out)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---- Poly1305(RFC 8439 §2.5)-------------------------------------------
|
|
134
|
+
|
|
135
|
+
# 生效条件:key、msg 为入参,取 key[:16] 掩码得 r、key[16:] 得 s,msg 按 16 字节分块加 b"\x01" 累加,返回 (acc + s) 低 128 位的 16 字节小端;源码未校验 key 长度或 msg 类型。
|
|
136
|
+
def _poly1305(key, msg):
|
|
137
|
+
r = int.from_bytes(key[:16], "little") & 0x0FFFFFFC0FFFFFFC0FFFFFFC0FFFFFFF
|
|
138
|
+
s = int.from_bytes(key[16:], "little")
|
|
139
|
+
p = (1 << 130) - 5
|
|
140
|
+
acc = 0
|
|
141
|
+
for i in range(0, len(msg), 16):
|
|
142
|
+
n = int.from_bytes(msg[i:i + 16] + b"\x01", "little")
|
|
143
|
+
acc = ((acc + n) * r) % p
|
|
144
|
+
return ((acc + s) & ((1 << 128) - 1)).to_bytes(16, "little")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# 生效条件:b 为入参,返回 b"\x00" * ((16 - len(b) % 16) % 16);b 长度为 16 的倍数(含空)时返回 b""。
|
|
148
|
+
def _pad16(b):
|
|
149
|
+
return b"\x00" * ((16 - len(b) % 16) % 16)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# 生效条件:otk、aad、ct 为入参,返回 _poly1305(otk, aad + _pad16(aad) + ct + _pad16(ct) + struct.pack("<Q", len(aad)) + struct.pack("<Q", len(ct))) 的结果。
|
|
153
|
+
def _aead_mac(otk, aad, ct):
|
|
154
|
+
return _poly1305(otk, aad + _pad16(aad) + ct + _pad16(ct)
|
|
155
|
+
+ struct.pack("<Q", len(aad)) + struct.pack("<Q", len(ct)))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# ---- AEAD:ChaCha20-Poly1305(RFC 8439 §2.8)-----------------------------
|
|
159
|
+
|
|
160
|
+
# 生效条件:key、nonce、plaintext、aad=b"" 为入参;len(key) != KEY_LEN 或 len(nonce) != NONCE_LEN 时抛 CryptoError;否则以 _chacha_block(key,0,nonce)[:32] 为 otk、_chacha20_xor(key,1,nonce,plaintext) 为 ct,返回 (ct, _aead_mac(otk, aad, ct))。
|
|
161
|
+
def aead_encrypt(key, nonce, plaintext, aad=b""):
|
|
162
|
+
"""返回 (ciphertext, tag)。key=32B / nonce=12B。"""
|
|
163
|
+
if len(key) != KEY_LEN:
|
|
164
|
+
raise CryptoError(f"密钥长度须为 {KEY_LEN} 字节")
|
|
165
|
+
if len(nonce) != NONCE_LEN:
|
|
166
|
+
raise CryptoError(f"nonce 长度须为 {NONCE_LEN} 字节")
|
|
167
|
+
otk = _chacha_block(key, 0, nonce)[:32]
|
|
168
|
+
ct = _chacha20_xor(key, 1, nonce, plaintext)
|
|
169
|
+
return ct, _aead_mac(otk, aad, ct)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# 生效条件:key、nonce、ct、tag、aad=b"" 为入参;len(key) != KEY_LEN 或 len(nonce) != NONCE_LEN 时抛 CryptoError;否则算 otk,若 hmac.compare_digest(_aead_mac(otk,aad,ct), tag) 为假抛 CryptoError,为真返回 _chacha20_xor(key,1,nonce,ct)。
|
|
173
|
+
def aead_decrypt(key, nonce, ct, tag, aad=b""):
|
|
174
|
+
"""验签后解密;失败抛 CryptoError(不返回任何明文)。"""
|
|
175
|
+
if len(key) != KEY_LEN:
|
|
176
|
+
raise CryptoError(f"密钥长度须为 {KEY_LEN} 字节")
|
|
177
|
+
if len(nonce) != NONCE_LEN:
|
|
178
|
+
raise CryptoError(f"nonce 长度须为 {NONCE_LEN} 字节")
|
|
179
|
+
otk = _chacha_block(key, 0, nonce)[:32]
|
|
180
|
+
if not hmac.compare_digest(_aead_mac(otk, aad, ct), tag):
|
|
181
|
+
raise CryptoError("认证标签校验失败(密钥/身份/节点不匹配或密文被篡改)")
|
|
182
|
+
return _chacha20_xor(key, 1, nonce, ct)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---- KDF / 主密钥(KEK)--------------------------------------------------
|
|
186
|
+
|
|
187
|
+
# 生效条件:b 为入参,返回 base64.b64encode(b).decode("ascii") 得到的字符串。
|
|
188
|
+
def _b64e(b):
|
|
189
|
+
return base64.b64encode(b).decode("ascii")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# 生效条件:s 为入参,返回 base64.b64decode(str(s).encode("ascii")) 的结果;默认 validate=False,非字母字符被丢弃,可能返回空字节或部分数据,源码未校验 s 合法性。
|
|
193
|
+
def _b64d(s):
|
|
194
|
+
return base64.b64decode(str(s).encode("ascii"))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# 生效条件:passphrase、salt 为入参,将 str(passphrase).encode("utf-8") 作为口令、salt 作为盐,按 SCRYPT_N、SCRYPT_R、SCRYPT_P、SCRYPT_DKLEN 调 hashlib.scrypt 返回 KEK。
|
|
198
|
+
def scrypt_kek(passphrase, salt):
|
|
199
|
+
"""口令 → KEK(scrypt)。用于「人类口令」场景,避免直接存放原始密钥。"""
|
|
200
|
+
return hashlib.scrypt(str(passphrase).encode("utf-8"), salt=salt,
|
|
201
|
+
n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P, dklen=SCRYPT_DKLEN)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# 生效条件:master_file=None、env_var=MASTER_ENV、create=True 为可选入参;从 os.environ.get(env_var) 读取并 strip,若 raw 非空则长度 64 走 bytes.fromhex、否则 _b64d,解码失败抛 CryptoError;否则 path = master_file or MASTER_FILE,若 os.path.exists(path) 为真则读取并 _b64d;若 create 为假返回 None;否则生成 KEY_LEN 随机密钥写入 path 并返回 key。
|
|
205
|
+
def load_master_key(master_file=None, env_var=MASTER_ENV, create=True):
|
|
206
|
+
"""KEK 来源:环境变量 → 主密钥文件(可选自动生成);都没有返回 None。"""
|
|
207
|
+
raw = (os.environ.get(env_var) or "").strip()
|
|
208
|
+
if raw:
|
|
209
|
+
try:
|
|
210
|
+
return bytes.fromhex(raw) if len(raw) == 64 else _b64d(raw)
|
|
211
|
+
except ValueError as e:
|
|
212
|
+
raise CryptoError(f"环境变量 {env_var} 不是合法 hex / base64 主密钥") from e
|
|
213
|
+
path = master_file or MASTER_FILE
|
|
214
|
+
if os.path.exists(path):
|
|
215
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
216
|
+
return _b64d(f.read().strip())
|
|
217
|
+
if not create:
|
|
218
|
+
return None
|
|
219
|
+
key = secrets.token_bytes(KEY_LEN)
|
|
220
|
+
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
221
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
222
|
+
f.write(_b64e(key))
|
|
223
|
+
try:
|
|
224
|
+
os.chmod(path, 0o600)
|
|
225
|
+
except OSError:
|
|
226
|
+
pass
|
|
227
|
+
return key
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# 生效条件:kek 为入参,返回 hashlib.sha256(b"mdcg-kek|" + kek).hexdigest()[:16];源码未校验 kek 类型。
|
|
231
|
+
def kek_fingerprint(kek):
|
|
232
|
+
return hashlib.sha256(b"mdcg-kek|" + kek).hexdigest()[:16]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ---- 身份一致性(AAD 绑定)----------------------------------------------
|
|
236
|
+
|
|
237
|
+
# 生效条件:tenant、actor 为入参,返回 hashlib.sha256(f"mdcg-id|v{ENVELOPE_VERSION}|{tenant}|{actor}".encode("utf-8")).hexdigest()[:16];ENVELOPE_VERSION 为模块级常量。
|
|
238
|
+
def identity_fingerprint(tenant, actor):
|
|
239
|
+
"""身份指纹:tenant + actor 的确定性摘要(不泄露原文)。"""
|
|
240
|
+
raw = f"mdcg-id|v{ENVELOPE_VERSION}|{tenant}|{actor}".encode("utf-8")
|
|
241
|
+
return hashlib.sha256(raw).hexdigest()[:16]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# 生效条件:tenant、actor 为入参,返回 f"mdcg-dek|v{ENVELOPE_VERSION}|{tenant}|{actor}".encode("utf-8")。
|
|
245
|
+
def _dek_aad(tenant, actor):
|
|
246
|
+
return f"mdcg-dek|v{ENVELOPE_VERSION}|{tenant}|{actor}".encode("utf-8")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# 生效条件:node_id、tenant、actor 为入参,返回 f"mdcg-node|v{ENVELOPE_VERSION}|{tenant}|{actor}|{node_id}".encode("utf-8")。
|
|
250
|
+
def _node_aad(node_id, tenant, actor):
|
|
251
|
+
return (f"mdcg-node|v{ENVELOPE_VERSION}|{tenant}|{actor}|{node_id}"
|
|
252
|
+
.encode("utf-8"))
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---- 密钥库(DEK 信封)---------------------------------------------------
|
|
256
|
+
|
|
257
|
+
# 生效条件:root 为入参,返回 os.path.join(root, KEYS_FILE);KEYS_FILE 为模块级常量。
|
|
258
|
+
def keys_path(root):
|
|
259
|
+
return os.path.join(root, KEYS_FILE)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# 生效条件:root 为入参;若 os.path.exists(keys_path(root)) 为假,返回 {"v": ENVELOPE_VERSION, "alg": ALG, "envelopes": {}};否则尝试 json.load,若结果为 dict 且其 "envelopes" 为 dict 则返回该 dict;若 json 解析 ValueError 或 OSError 则返回同样的空结构。
|
|
263
|
+
def _load_keys(root):
|
|
264
|
+
p = keys_path(root)
|
|
265
|
+
if not os.path.exists(p):
|
|
266
|
+
return {"v": ENVELOPE_VERSION, "alg": ALG, "envelopes": {}}
|
|
267
|
+
try:
|
|
268
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
269
|
+
d = json.load(f)
|
|
270
|
+
if isinstance(d, dict) and isinstance(d.get("envelopes"), dict):
|
|
271
|
+
return d
|
|
272
|
+
except (ValueError, OSError):
|
|
273
|
+
pass
|
|
274
|
+
return {"v": ENVELOPE_VERSION, "alg": ALG, "envelopes": {}}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# 生效条件:root、data 为入参,将 data 经 json.dumps(data, ensure_ascii=False, indent=1) 后由 atomic_write 写入 keys_path(root)。
|
|
278
|
+
def _save_keys(root, data):
|
|
279
|
+
from .fsutil import atomic_write
|
|
280
|
+
atomic_write(keys_path(root),
|
|
281
|
+
json.dumps(data, ensure_ascii=False, indent=1))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# 生效条件:tenant、actor 为入参,返回 f"{tenant}|{actor}"。
|
|
285
|
+
def _envelope_key(tenant, actor):
|
|
286
|
+
return f"{tenant}|{actor}"
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# 生效条件:root、kek、tenant、actor、clearance="private"、rotate=False 为入参;若 kek 为假抛 LockedError;否则加载 keys,若 _envelope_key(tenant, actor) 已在 envelopes 中且 rotate 为假则返回 unwrap_dek(root, kek, tenant, actor, clearance);否则生成新 DEK 与 nonce,用 aead_encrypt(kek, nonce, dek, _dek_aad(tenant, actor)) 包裹后写入 envelopes[k] 并保存,返回 dek。
|
|
290
|
+
def provision_dek(root, kek, tenant, actor, clearance="private", rotate=False):
|
|
291
|
+
"""为 (tenant, actor) 生成 / 取回 DEK,用 KEK 包裹后存入 `_keys.json`。
|
|
292
|
+
|
|
293
|
+
rotate=True 强制换新密钥(旧密文需先迁移,见 `reencrypt_all`)。
|
|
294
|
+
"""
|
|
295
|
+
if not kek:
|
|
296
|
+
raise LockedError("无主密钥(KEK):拒绝签发数据密钥")
|
|
297
|
+
data = _load_keys(root)
|
|
298
|
+
data.setdefault("envelopes", {})
|
|
299
|
+
k = _envelope_key(tenant, actor)
|
|
300
|
+
if k in data["envelopes"] and not rotate:
|
|
301
|
+
return unwrap_dek(root, kek, tenant, actor, clearance)
|
|
302
|
+
dek = secrets.token_bytes(KEY_LEN)
|
|
303
|
+
nonce = secrets.token_bytes(NONCE_LEN)
|
|
304
|
+
ct, tag = aead_encrypt(kek, nonce, dek, _dek_aad(tenant, actor))
|
|
305
|
+
data["envelopes"][k] = {
|
|
306
|
+
"id_fp": identity_fingerprint(tenant, actor),
|
|
307
|
+
"clearance": clearance,
|
|
308
|
+
"nonce": _b64e(nonce), "ct": _b64e(ct + tag),
|
|
309
|
+
"created_at": time.time(),
|
|
310
|
+
}
|
|
311
|
+
data["v"] = ENVELOPE_VERSION
|
|
312
|
+
data["alg"] = ALG
|
|
313
|
+
_save_keys(root, data)
|
|
314
|
+
return dek
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
# 生效条件:root、kek、tenant、actor、clearance=None 为入参;若 kek 为假抛 LockedError;否则取 _load_keys(root)["envelopes"] 中 _envelope_key(tenant, actor) 的信封,无则抛 LockedError;若 env["id_fp"] 不等于 identity_fingerprint(tenant, actor) 抛 LockedError;否则从 env["ct"] 解出 raw,按 TAG_LEN 切出 ct/tag,调 aead_decrypt;若 aead_decrypt 抛 CryptoError 则转抛 LockedError;clearance 形参默认 None 但源码未在条件中使用。
|
|
318
|
+
def unwrap_dek(root, kek, tenant, actor, clearance=None):
|
|
319
|
+
"""解出 DEK;身份指纹不符 / KEK 不对 / 无信封 → LockedError。"""
|
|
320
|
+
if not kek:
|
|
321
|
+
raise LockedError("无主密钥(KEK):无法解开数据密钥")
|
|
322
|
+
env = (_load_keys(root).get("envelopes") or {}).get(_envelope_key(tenant, actor))
|
|
323
|
+
if not env:
|
|
324
|
+
raise LockedError(f"无 {tenant}|{actor} 的密钥信封")
|
|
325
|
+
if env.get("id_fp") != identity_fingerprint(tenant, actor):
|
|
326
|
+
raise LockedError("身份指纹不符:密钥信封不属于当前身份")
|
|
327
|
+
raw = _b64d(env["ct"])
|
|
328
|
+
ct, tag = raw[:-TAG_LEN], raw[-TAG_LEN:]
|
|
329
|
+
try:
|
|
330
|
+
return aead_decrypt(kek, _b64d(env["nonce"]), ct, tag,
|
|
331
|
+
_dek_aad(tenant, actor))
|
|
332
|
+
except CryptoError as e:
|
|
333
|
+
raise LockedError(f"身份 / 主密钥不匹配:{e}") from e
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# 生效条件:root、tenant、actor 为入参,返回 _envelope_key(tenant, actor) in (_load_keys(root).get("envelopes") or {}) 的布尔结果。
|
|
337
|
+
def has_envelope(root, tenant, actor):
|
|
338
|
+
return _envelope_key(tenant, actor) in (
|
|
339
|
+
_load_keys(root).get("envelopes") or {})
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# 生效条件:root、kek、tenant、actor、clearance="private" 为入参,返回 provision_dek(root, kek, tenant, actor, clearance=clearance, rotate=True)。
|
|
343
|
+
def rotate_dek(root, kek, tenant, actor, clearance="private"):
|
|
344
|
+
return provision_dek(root, kek, tenant, actor, clearance=clearance,
|
|
345
|
+
rotate=True)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# 生效条件:root 为入参,遍历 _load_keys(root).get("envelopes") or {} 的每项,按 "|" partition 出 tenant/actor,收集 id_fp、clearance、created_at 后返回列表;无信封时返回 []。
|
|
349
|
+
def envelopes(root):
|
|
350
|
+
"""信封清单(不含密钥材料):供运维审计「谁被签发了密钥」。"""
|
|
351
|
+
out = []
|
|
352
|
+
for k, v in (_load_keys(root).get("envelopes") or {}).items():
|
|
353
|
+
tenant, _, actor = k.partition("|")
|
|
354
|
+
out.append({"tenant": tenant, "actor": actor,
|
|
355
|
+
"id_fp": v.get("id_fp"), "clearance": v.get("clearance"),
|
|
356
|
+
"created_at": v.get("created_at")})
|
|
357
|
+
return out
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ---- 节点正文封装 --------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
# 生效条件:content 为入参;bool(content) 为假(如空串或 None)时返回 False;否则返回 content.lstrip().startswith(ENC_PREFIX)。
|
|
363
|
+
def is_encrypted(content):
|
|
364
|
+
return bool(content) and content.lstrip().startswith(ENC_PREFIX)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# 生效条件:content、dek、node_id、tenant、actor 为入参,生成 NONCE_LEN 随机 nonce,将 str(content).encode("utf-8") 以 aead_encrypt(dek, nonce, ..., _node_aad(node_id, tenant, actor)) 加密,返回 f"{ENC_PREFIX}{_b64e(nonce + tag + ct)}{ENC_SUFFIX}"。
|
|
368
|
+
def seal_node(content, dek, node_id, tenant, actor):
|
|
369
|
+
"""明文 → 密文标记块(正文整体加密;frontmatter 不在此处处理)。"""
|
|
370
|
+
nonce = secrets.token_bytes(NONCE_LEN)
|
|
371
|
+
ct, tag = aead_encrypt(dek, nonce, str(content).encode("utf-8"),
|
|
372
|
+
_node_aad(node_id, tenant, actor))
|
|
373
|
+
return f"{ENC_PREFIX}{_b64e(nonce + tag + ct)}{ENC_SUFFIX}"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# 生效条件:content、dek、node_id、tenant、actor 为入参;若 is_encrypted(content) 为假则原样返回 content;否则 strip 后切掉 ENC_PREFIX/ENC_SUFFIX,base64 解出 raw,按 NONCE_LEN、TAG_LEN 切出 nonce/tag/ct,调 aead_decrypt(dek, nonce, ct, tag, _node_aad(node_id, tenant, actor)) 并 utf-8 解码返回;aead_decrypt 失败抛 CryptoError。
|
|
377
|
+
def open_node(content, dek, node_id, tenant, actor):
|
|
378
|
+
"""密文标记块 → 明文;未加密原样返回;失败抛 CryptoError。"""
|
|
379
|
+
if not is_encrypted(content):
|
|
380
|
+
return content
|
|
381
|
+
body = content.strip()
|
|
382
|
+
body = body[len(ENC_PREFIX):-len(ENC_SUFFIX)]
|
|
383
|
+
raw = _b64d(body)
|
|
384
|
+
nonce = raw[:NONCE_LEN]
|
|
385
|
+
tag = raw[NONCE_LEN:NONCE_LEN + TAG_LEN]
|
|
386
|
+
ct = raw[NONCE_LEN + TAG_LEN:]
|
|
387
|
+
return aead_decrypt(dek, nonce, ct, tag,
|
|
388
|
+
_node_aad(node_id, tenant, actor)).decode("utf-8")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# ---- 审计(payload-free)-------------------------------------------------
|
|
392
|
+
|
|
393
|
+
# 生效条件:root、rec 为入参,复制 rec,若未提供 ts 则设 time.time(),设 payload_free=True,尝试 append_jsonl(os.path.join(root, AUDIT_FILE), rec);OSError 时静默忽略。
|
|
394
|
+
def audit(root, rec):
|
|
395
|
+
from .fsutil import append_jsonl
|
|
396
|
+
rec = dict(rec)
|
|
397
|
+
rec.setdefault("ts", time.time())
|
|
398
|
+
rec["payload_free"] = True
|
|
399
|
+
try:
|
|
400
|
+
append_jsonl(os.path.join(root, AUDIT_FILE), rec)
|
|
401
|
+
except OSError:
|
|
402
|
+
pass
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# 生效条件:root 为入参,返回 list(read_jsonl(os.path.join(root, AUDIT_FILE))) 得到的记录列表。
|
|
406
|
+
def audit_records(root):
|
|
407
|
+
from .fsutil import read_jsonl
|
|
408
|
+
return list(read_jsonl(os.path.join(root, AUDIT_FILE)))
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ---- 自描述 --------------------------------------------------------------
|
|
412
|
+
|
|
413
|
+
# 生效条件:无入参,返回包含 module="crypto"、ALG、ENCRYPTED_LEVELS、MASTER_ENV、KEYS_FILE 等模块级常量的自描述字典。
|
|
414
|
+
def catalog():
|
|
415
|
+
"""自描述:加密范围、密钥层级、身份一致性、威胁模型(供 MCP 对照)。"""
|
|
416
|
+
return {
|
|
417
|
+
"module": "crypto",
|
|
418
|
+
"alg": ALG,
|
|
419
|
+
"rfc": "RFC 8439(ChaCha20-Poly1305)",
|
|
420
|
+
"encrypted_levels": list(ENCRYPTED_LEVELS),
|
|
421
|
+
"key_hierarchy": {
|
|
422
|
+
"KEK": f"环境变量 {MASTER_ENV} 或主密钥文件(仓库外,0600)",
|
|
423
|
+
"DEK": f"每 (tenant, actor) 一把,用 KEK 包裹后存 {KEYS_FILE}",
|
|
424
|
+
"node": "DEK + 每节点随机 nonce 做 AEAD",
|
|
425
|
+
},
|
|
426
|
+
"identity_binding": [
|
|
427
|
+
"DEK 信封 AAD = mdcg-dek|v|tenant|actor",
|
|
428
|
+
"节点 AAD = mdcg-node|v|tenant|actor|node_id",
|
|
429
|
+
"信封另存 id_fp 指纹,解密前先比对(快速失败)",
|
|
430
|
+
],
|
|
431
|
+
"plaintext_metadata": ["layer", "tags", "condition_space", "importance",
|
|
432
|
+
"sensitivity", "created_at"],
|
|
433
|
+
"ciphertext_limits": "密文不参与全文索引;无密钥者只能走元数据路",
|
|
434
|
+
"threat_model": {
|
|
435
|
+
"covered": ["磁盘 / 备份 / 仓库泄露", "无密钥读取",
|
|
436
|
+
"跨身份 / 跨节点密文挪用"],
|
|
437
|
+
"not_covered": ["本地内存取证", "侧信道(纯 Python 实现的固有限制)"],
|
|
438
|
+
},
|
|
439
|
+
"fail_closed": "无 KEK 时拒绝写入 private/secret,不静默降级为明文",
|
|
438
440
|
}
|