cortexm 0.3.0__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.
- context_m.py +17 -0
- cortexm/__init__.py +45 -0
- cortexm/accel.py +403 -0
- cortexm/api/__init__.py +0 -0
- cortexm/api/chaos.py +118 -0
- cortexm/api/memory.py +635 -0
- cortexm/bench/__init__.py +0 -0
- cortexm/bench/abilities.py +311 -0
- cortexm/bench/baselines.py +89 -0
- cortexm/bench/beam_loader.py +317 -0
- cortexm/bench/generator.py +376 -0
- cortexm/bench/harness.py +211 -0
- cortexm/bench/messy.py +218 -0
- cortexm/bench/micro.py +251 -0
- cortexm/bench/ood.py +443 -0
- cortexm/bench/run.py +137 -0
- cortexm/bridge/__init__.py +0 -0
- cortexm/bridge/dates.py +178 -0
- cortexm/bridge/decoders.py +204 -0
- cortexm/bridge/enrich.py +255 -0
- cortexm/bridge/extractor.py +316 -0
- cortexm/bridge/fallback.py +332 -0
- cortexm/bridge/onnx_runtime.py +158 -0
- cortexm/bridge/patterns.py +760 -0
- cortexm/bridge/ppr.py +104 -0
- cortexm/bridge/prefilter.py +188 -0
- cortexm/bridge/query_extract.py +420 -0
- cortexm/bridge/reader.py +1174 -0
- cortexm/bridge/rerank.py +204 -0
- cortexm/bridge/writer.py +492 -0
- cortexm/cli.py +295 -0
- cortexm/cognition/__init__.py +53 -0
- cortexm/cognition/abstraction.py +192 -0
- cortexm/cognition/analogy.py +159 -0
- cortexm/cognition/engine.py +204 -0
- cortexm/cognition/gaps.py +365 -0
- cortexm/cognition/scanner.py +204 -0
- cortexm/config.py +375 -0
- cortexm/cortexm.py +8 -0
- cortexm/enterprise/__init__.py +0 -0
- cortexm/enterprise/audit.py +178 -0
- cortexm/enterprise/governance.py +239 -0
- cortexm/errors.py +35 -0
- cortexm/features/__init__.py +0 -0
- cortexm/features/git.py +204 -0
- cortexm/features/prefetch.py +88 -0
- cortexm/features/zk.py +105 -0
- cortexm/federation/__init__.py +39 -0
- cortexm/federation/crdt.py +275 -0
- cortexm/federation/fabric.py +109 -0
- cortexm/federation/hlc.py +80 -0
- cortexm/federation/node.py +145 -0
- cortexm/federation/schema_report.py +73 -0
- cortexm/federation/transport.py +164 -0
- cortexm/index/__init__.py +19 -0
- cortexm/index/nsg.py +386 -0
- cortexm/mcp/__init__.py +0 -0
- cortexm/mcp/server.py +985 -0
- cortexm/metrics.py +62 -0
- cortexm/migrate/__init__.py +0 -0
- cortexm/migrate/importers.py +192 -0
- cortexm/provenance/__init__.py +78 -0
- cortexm/provenance/agent.py +214 -0
- cortexm/provenance/cose.py +201 -0
- cortexm/provenance/scitt.py +258 -0
- cortexm/provenance/vc.py +250 -0
- cortexm/security/__init__.py +0 -0
- cortexm/security/crypto.py +162 -0
- cortexm/security/hashes.py +140 -0
- cortexm/security/injection.py +149 -0
- cortexm/security/mind.py +154 -0
- cortexm/security/pii.py +265 -0
- cortexm/security/rbac.py +169 -0
- cortexm/security/sandbox.py +131 -0
- cortexm/security/zk_hamming.py +142 -0
- cortexm/security/zk_sql.py +485 -0
- cortexm/server/__init__.py +0 -0
- cortexm/server/metrics.py +88 -0
- cortexm/server/rest.py +936 -0
- cortexm/server/sparql.py +984 -0
- cortexm/text/__init__.py +0 -0
- cortexm/text/dissim.py +252 -0
- cortexm/text/embedder.py +155 -0
- cortexm/text/fuzzy.py +218 -0
- cortexm/text/idiolect.py +253 -0
- cortexm/text/labse.py +374 -0
- cortexm/text/tokenizer.py +79 -0
- cortexm/trace/__init__.py +0 -0
- cortexm/trace/blob_arena.py +277 -0
- cortexm/trace/consolidate.py +337 -0
- cortexm/trace/contradictions.py +69 -0
- cortexm/trace/dedup.py +114 -0
- cortexm/trace/edges.py +214 -0
- cortexm/trace/fact.py +121 -0
- cortexm/trace/fade.py +245 -0
- cortexm/trace/lifecycle.py +112 -0
- cortexm/trace/rebuild.py +173 -0
- cortexm/trace/rules.py +171 -0
- cortexm/trace/store.py +680 -0
- cortexm/trace/structural.py +183 -0
- cortexm/trace/tmt.py +335 -0
- cortexm/util.py +148 -0
- cortexm/vsa/__init__.py +0 -0
- cortexm/vsa/attribution.py +149 -0
- cortexm/vsa/cleanup.py +161 -0
- cortexm/vsa/codecs.py +397 -0
- cortexm/vsa/hologram_overlay.py +139 -0
- cortexm/vsa/index.py +163 -0
- cortexm/vsa/ops.py +149 -0
- cortexm/vsa/palace.py +446 -0
- cortexm/vsa/role_vectors.py +236 -0
- cortexm/vsa/slb.py +78 -0
- cortexm/vsa/tlsh_trie.py +137 -0
- cortexm/vsa/working_memory.py +249 -0
- cortexm-0.3.0.dist-info/METADATA +482 -0
- cortexm-0.3.0.dist-info/RECORD +120 -0
- cortexm-0.3.0.dist-info/WHEEL +5 -0
- cortexm-0.3.0.dist-info/entry_points.txt +2 -0
- cortexm-0.3.0.dist-info/licenses/LICENSE +190 -0
- cortexm-0.3.0.dist-info/top_level.txt +2 -0
cortexm/provenance/vc.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""W3C Verifiable Credentials 2.0 export for memory ranges.
|
|
2
|
+
|
|
3
|
+
A memory range (e.g. "all facts about user=alice from 2026-01-01 to
|
|
4
|
+
2026-02-01") can be exported as a W3C Verifiable Credential with an
|
|
5
|
+
eddsa-jcs-2022 Data Integrity proof.
|
|
6
|
+
|
|
7
|
+
The VC contains:
|
|
8
|
+
- The BLAKE3 root of the range (the Merkle root over the fact hashes)
|
|
9
|
+
- The range query (user_id, valid_from, valid_to)
|
|
10
|
+
- The number of facts in the range
|
|
11
|
+
- The COSE Sign1 envelope that proves the agent signed it
|
|
12
|
+
|
|
13
|
+
External verifiers can confirm:
|
|
14
|
+
(1) The range query is well-formed
|
|
15
|
+
(2) The agent signed the range (via the COSE Sign1)
|
|
16
|
+
(3) The BLAKE3 root is correct (by recomputing it from the facts)
|
|
17
|
+
— without seeing the individual facts themselves.
|
|
18
|
+
|
|
19
|
+
This implements the W3C VC Data Model 2.0 + the eddsa-jcs-2022
|
|
20
|
+
cryptosuite. JSON Canonicalization Scheme (JCS) is RFC 8785.
|
|
21
|
+
|
|
22
|
+
Reference:
|
|
23
|
+
- W3C VC Data Model 2.0: https://www.w3.org/TR/vc-data-model-2.0/
|
|
24
|
+
- eddsa-jcs-2022 cryptosuite:
|
|
25
|
+
https://www.w3.org/TR/vc-di-eddsa/
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
from cortexm.provenance.agent import Ed25519AgentKey, get_default_agent
|
|
37
|
+
from cortexm.provenance.cose import CoseSign1Envelope, sign_commit
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class VerifiableCredential:
|
|
42
|
+
"""A W3C VC 2.0 with an eddsa-jcs-2022 Data Integrity proof."""
|
|
43
|
+
context: list[str] = field(default_factory=lambda: [
|
|
44
|
+
"https://www.w3.org/ns/credentials/v2",
|
|
45
|
+
"https://context-m.dev/ns/memory/v1",
|
|
46
|
+
])
|
|
47
|
+
id: str = ""
|
|
48
|
+
type: list[str] = field(default_factory=lambda: [
|
|
49
|
+
"VerifiableCredential", "MemoryRangeCredential"
|
|
50
|
+
])
|
|
51
|
+
issuer: str = "" # did:key of the issuing agent
|
|
52
|
+
valid_from: str = "" # ISO timestamp
|
|
53
|
+
credential_subject: dict = field(default_factory=dict)
|
|
54
|
+
proof: dict = field(default_factory=dict)
|
|
55
|
+
|
|
56
|
+
def to_dict(self) -> dict:
|
|
57
|
+
return {
|
|
58
|
+
"@context": self.context,
|
|
59
|
+
"id": self.id,
|
|
60
|
+
"type": self.type,
|
|
61
|
+
"issuer": self.issuer,
|
|
62
|
+
"validFrom": self.valid_from,
|
|
63
|
+
"credentialSubject": self.credential_subject,
|
|
64
|
+
"proof": self.proof,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
def to_json(self) -> str:
|
|
68
|
+
return json.dumps(self.to_dict(), sort_keys=True, indent=2)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _merkle_root(hashes: list[str]) -> str:
|
|
72
|
+
"""Compute a Merkle root over a list of BLAKE3 fact hashes.
|
|
73
|
+
|
|
74
|
+
For <2 hashes: returns the single hash (or empty string if 0).
|
|
75
|
+
For 2+ hashes: pairwise concat + BLAKE3, repeat until 1 remains.
|
|
76
|
+
Pads odd levels by repeating the last hash.
|
|
77
|
+
"""
|
|
78
|
+
if not hashes:
|
|
79
|
+
return ""
|
|
80
|
+
if len(hashes) == 1:
|
|
81
|
+
return hashes[0]
|
|
82
|
+
level = list(hashes)
|
|
83
|
+
while len(level) > 1:
|
|
84
|
+
if len(level) % 2 == 1:
|
|
85
|
+
level.append(level[-1]) # pad
|
|
86
|
+
next_level = []
|
|
87
|
+
for i in range(0, len(level), 2):
|
|
88
|
+
combined = (level[i] + level[i + 1]).encode("utf-8")
|
|
89
|
+
try:
|
|
90
|
+
import blake3
|
|
91
|
+
h = blake3.blake3(combined).hexdigest()
|
|
92
|
+
except ImportError:
|
|
93
|
+
h = hashlib.blake2b(combined, digest_size=32).hexdigest()
|
|
94
|
+
next_level.append(h)
|
|
95
|
+
level = next_level
|
|
96
|
+
return level[0]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _jcs_canonical(obj: dict) -> str:
|
|
100
|
+
"""JSON Canonicalization Scheme (RFC 8785).
|
|
101
|
+
|
|
102
|
+
Sorts keys recursively, uses minimal whitespace, escapes per RFC.
|
|
103
|
+
Python's json.dumps with sort_keys=True + separators=(",", ":")
|
|
104
|
+
produces output that mostly matches JCS, with one caveat: unicode
|
|
105
|
+
escapes. JCS requires unescaped non-ASCII, which Python's
|
|
106
|
+
ensure_ascii=False provides.
|
|
107
|
+
"""
|
|
108
|
+
return json.dumps(obj, sort_keys=True, separators=(",", ":"),
|
|
109
|
+
ensure_ascii=False)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def export_memory_range_vc(store, *,
|
|
113
|
+
user_id: str | None = None,
|
|
114
|
+
valid_from: str | None = None,
|
|
115
|
+
valid_to: str | None = None,
|
|
116
|
+
include_hypotheses: bool = False,
|
|
117
|
+
agent: Ed25519AgentKey | None = None,
|
|
118
|
+
issuer_id: str | None = None) -> VerifiableCredential:
|
|
119
|
+
"""Export a memory range as a W3C Verifiable Credential.
|
|
120
|
+
|
|
121
|
+
The VC reveals:
|
|
122
|
+
- The range query (user_id, valid_from, valid_to)
|
|
123
|
+
- The number of facts in the range
|
|
124
|
+
- The BLAKE3 Merkle root over the fact hashes
|
|
125
|
+
- The issuing agent's did:key
|
|
126
|
+
|
|
127
|
+
The VC does NOT reveal:
|
|
128
|
+
- Individual fact values (only the aggregate Merkle root)
|
|
129
|
+
- The underlying chunks (only metadata + root)
|
|
130
|
+
"""
|
|
131
|
+
if agent is None:
|
|
132
|
+
agent = get_default_agent()
|
|
133
|
+
|
|
134
|
+
where = "is_active=1 AND quarantined=0"
|
|
135
|
+
args: list = []
|
|
136
|
+
if user_id is not None:
|
|
137
|
+
where += " AND user_id=?"
|
|
138
|
+
args.append(user_id)
|
|
139
|
+
if valid_from is not None:
|
|
140
|
+
where += " AND valid_from >= ?"
|
|
141
|
+
args.append(valid_from)
|
|
142
|
+
if valid_to is not None:
|
|
143
|
+
where += " AND valid_to <= ?"
|
|
144
|
+
args.append(valid_to)
|
|
145
|
+
if not include_hypotheses:
|
|
146
|
+
# exclude derived facts (hypotheses, abstractions, analogies)
|
|
147
|
+
where += " AND is_derived=0"
|
|
148
|
+
|
|
149
|
+
rows = store.conn.execute(
|
|
150
|
+
f"SELECT id, source_hash FROM facts WHERE {where} "
|
|
151
|
+
f"ORDER BY valid_from, id", args).fetchall()
|
|
152
|
+
|
|
153
|
+
hashes = [r[1] or r[0] for r in rows] # prefer source_hash, fallback to id
|
|
154
|
+
root = _merkle_root(hashes)
|
|
155
|
+
|
|
156
|
+
credential_subject = {
|
|
157
|
+
"range": {
|
|
158
|
+
"user_id": user_id,
|
|
159
|
+
"valid_from": valid_from,
|
|
160
|
+
"valid_to": valid_to,
|
|
161
|
+
},
|
|
162
|
+
"n_facts": len(rows),
|
|
163
|
+
"merkle_root": root,
|
|
164
|
+
"hash_algorithm": "BLAKE3-256",
|
|
165
|
+
"storage": "context-m-trace",
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
# build the proof: eddsa-jcs-2022 Data Integrity proof
|
|
169
|
+
ts_created = datetime.now(timezone.utc).isoformat()
|
|
170
|
+
proof_options = {
|
|
171
|
+
"type": "DataIntegrityProof",
|
|
172
|
+
"cryptosuite": "eddsa-jcs-2022",
|
|
173
|
+
"created": ts_created,
|
|
174
|
+
"verificationMethod": agent.did,
|
|
175
|
+
"proofPurpose": "assertionMethod",
|
|
176
|
+
}
|
|
177
|
+
# canonicalize the subject + proof options (minus the proofValue)
|
|
178
|
+
canon_input = {
|
|
179
|
+
"@context": [
|
|
180
|
+
"https://www.w3.org/ns/credentials/v2",
|
|
181
|
+
"https://context-m.dev/ns/memory/v1",
|
|
182
|
+
],
|
|
183
|
+
"type": ["VerifiableCredential", "MemoryRangeCredential"],
|
|
184
|
+
"issuer": agent.did,
|
|
185
|
+
"validFrom": ts_created,
|
|
186
|
+
"credentialSubject": credential_subject,
|
|
187
|
+
}
|
|
188
|
+
canon_input.update(proof_options)
|
|
189
|
+
canon_str = _jcs_canonical(canon_input)
|
|
190
|
+
signature = agent.sign(canon_str.encode("utf-8"))
|
|
191
|
+
|
|
192
|
+
import base64
|
|
193
|
+
proof = proof_options.copy()
|
|
194
|
+
proof["proofValue"] = base64.urlsafe_b64encode(signature).decode().rstrip("=")
|
|
195
|
+
|
|
196
|
+
vc = VerifiableCredential(
|
|
197
|
+
id=issuer_id or f"urn:uuid:{hashlib.sha256(canon_str.encode()).hexdigest()[:32]}",
|
|
198
|
+
issuer=agent.did,
|
|
199
|
+
valid_from=ts_created,
|
|
200
|
+
credential_subject=credential_subject,
|
|
201
|
+
proof=proof,
|
|
202
|
+
)
|
|
203
|
+
return vc
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def verify_vc(vc: VerifiableCredential,
|
|
207
|
+
agent: Ed25519AgentKey | None = None,
|
|
208
|
+
expected_root: str | None = None,
|
|
209
|
+
expected_n_facts: int | None = None) -> bool:
|
|
210
|
+
"""Verify a VerifiableCredential's Data Integrity proof."""
|
|
211
|
+
if agent is None:
|
|
212
|
+
agent = get_default_agent()
|
|
213
|
+
if agent.did != vc.issuer:
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
if expected_root and vc.credential_subject.get("merkle_root") != expected_root:
|
|
217
|
+
return False
|
|
218
|
+
if expected_n_facts is not None and vc.credential_subject.get("n_facts") != expected_n_facts:
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
proof = vc.proof
|
|
222
|
+
if proof.get("type") != "DataIntegrityProof":
|
|
223
|
+
return False
|
|
224
|
+
if proof.get("cryptosuite") != "eddsa-jcs-2022":
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
import base64
|
|
228
|
+
proof_value = proof.get("proofValue", "")
|
|
229
|
+
if not proof_value:
|
|
230
|
+
return False
|
|
231
|
+
signature = base64.urlsafe_b64decode(proof_value + "=" * (-len(proof_value) % 4))
|
|
232
|
+
|
|
233
|
+
# reconstruct canonical input (minus the proofValue)
|
|
234
|
+
proof_options = {k: v for k, v in proof.items() if k != "proofValue"}
|
|
235
|
+
canon_input = {
|
|
236
|
+
"@context": vc.context,
|
|
237
|
+
"type": vc.type,
|
|
238
|
+
"issuer": vc.issuer,
|
|
239
|
+
"validFrom": vc.valid_from,
|
|
240
|
+
"credentialSubject": vc.credential_subject,
|
|
241
|
+
}
|
|
242
|
+
canon_input.update(proof_options)
|
|
243
|
+
canon_str = _jcs_canonical(canon_input)
|
|
244
|
+
return agent.verify(canon_str.encode("utf-8"), signature)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
__all__ = [
|
|
248
|
+
"VerifiableCredential",
|
|
249
|
+
"export_memory_range_vc", "verify_vc",
|
|
250
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Encryption at rest — AES-256-GCM envelope encryption.
|
|
2
|
+
|
|
3
|
+
Threat model: attacker obtains the database file (disk theft, S3 bucket
|
|
4
|
+
leak, backup exposure) but not the master key. Every sensitive column —
|
|
5
|
+
chunk text (raw conversation), vault payloads, API key hashes' pepper —
|
|
6
|
+
is ciphertext in the file; the key never touches disk unless explicitly
|
|
7
|
+
exported to a key file (0600) or supplied via ``CONTEXT_M_MASTER_KEY``.
|
|
8
|
+
|
|
9
|
+
Design (standard envelope):
|
|
10
|
+
master key (KEK) — 32 bytes, from env / key file / generated
|
|
11
|
+
data key (DEK) — 32 bytes, random per database, wrapped by KEK,
|
|
12
|
+
stored in the ``kv`` table (``enc:dek``)
|
|
13
|
+
payloads — AES-256-GCM, 12-byte random nonce, AAD-bound
|
|
14
|
+
|
|
15
|
+
The cipher is injectable into TraceStore (field encryption) and the PII
|
|
16
|
+
vault. μ=0 is preserved: all primitives are symmetric, deterministic
|
|
17
|
+
given key material, and make zero network or LLM calls.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import os
|
|
24
|
+
import secrets
|
|
25
|
+
|
|
26
|
+
try: # cryptography >= 3.0 ships AESGCM
|
|
27
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
28
|
+
_HAVE_CRYPTO = True
|
|
29
|
+
except Exception: # pragma: no cover — documented degraded mode
|
|
30
|
+
_HAVE_CRYPTO = False
|
|
31
|
+
|
|
32
|
+
ENV_MASTER_KEY = "CONTEXT_M_MASTER_KEY"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CryptoUnavailable(RuntimeError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _b64e(b: bytes) -> str:
|
|
40
|
+
return base64.urlsafe_b64encode(b).decode()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _b64d(s: str) -> bytes:
|
|
44
|
+
return base64.urlsafe_b64decode(s.encode())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class AESGCMCipher:
|
|
48
|
+
"""One cipher object per data key. Encrypt/decrypt strings and bytes."""
|
|
49
|
+
|
|
50
|
+
FORMAT = "enc:v1" # versioned envelope, forward-compatible
|
|
51
|
+
PREFIX = "«enc:v1:"
|
|
52
|
+
|
|
53
|
+
def __init__(self, master_key: bytes, dek: bytes | None = None,
|
|
54
|
+
store=None) -> None:
|
|
55
|
+
if not _HAVE_CRYPTO:
|
|
56
|
+
raise CryptoUnavailable(
|
|
57
|
+
"pip install cryptography # required for encryption at rest")
|
|
58
|
+
if len(master_key) != 32:
|
|
59
|
+
raise ValueError("master key must be 32 bytes (256-bit)")
|
|
60
|
+
self._kek = master_key
|
|
61
|
+
self._store = store
|
|
62
|
+
if dek is not None:
|
|
63
|
+
self._dek = dek
|
|
64
|
+
elif store is not None:
|
|
65
|
+
self._dek = self._load_or_create_dek(store)
|
|
66
|
+
else:
|
|
67
|
+
self._dek = secrets.token_bytes(32)
|
|
68
|
+
|
|
69
|
+
# -------------------------------------------------------------- DEK mgmt
|
|
70
|
+
def _load_or_create_dek(self, store) -> bytes:
|
|
71
|
+
raw = store.kv_get("enc:dek")
|
|
72
|
+
if raw and raw.startswith(f"{self.FORMAT}:"):
|
|
73
|
+
try:
|
|
74
|
+
blob = _b64d(raw[len(self.FORMAT) + 1:])
|
|
75
|
+
nonce, wrapped = blob[:12], blob[12:]
|
|
76
|
+
return AESGCM(self._kek).decrypt(nonce, wrapped, b"context-m-dek")
|
|
77
|
+
except Exception:
|
|
78
|
+
raise CryptoUnavailable(
|
|
79
|
+
"cannot unwrap data key — wrong master key for this DB?")
|
|
80
|
+
dek = secrets.token_bytes(32)
|
|
81
|
+
nonce = secrets.token_bytes(12)
|
|
82
|
+
wrapped = AESGCM(self._kek).encrypt(nonce, dek, b"context-m-dek")
|
|
83
|
+
store.kv_set("enc:dek", f"{self.FORMAT}:{_b64e(nonce + wrapped)}")
|
|
84
|
+
return dek
|
|
85
|
+
|
|
86
|
+
# -------------------------------------------------------------- primitives
|
|
87
|
+
def encrypt(self, plaintext: bytes, aad: bytes = b"") -> str:
|
|
88
|
+
nonce = secrets.token_bytes(12)
|
|
89
|
+
ct = AESGCM(self._dek).encrypt(nonce, plaintext, aad)
|
|
90
|
+
return f"{self.PREFIX}{_b64e(nonce + ct)}»"
|
|
91
|
+
|
|
92
|
+
def decrypt(self, envelope: str, aad: bytes = b"") -> bytes:
|
|
93
|
+
if not envelope.startswith(self.PREFIX) or not envelope.endswith("»"):
|
|
94
|
+
raise ValueError("not an encrypted envelope")
|
|
95
|
+
blob = _b64d(envelope[len(self.PREFIX):-1])
|
|
96
|
+
nonce, ct = blob[:12], blob[12:]
|
|
97
|
+
return AESGCM(self._dek).decrypt(nonce, ct, aad)
|
|
98
|
+
|
|
99
|
+
def encrypt_str(self, s: str) -> str:
|
|
100
|
+
return self.encrypt(s.encode())
|
|
101
|
+
|
|
102
|
+
def decrypt_str(self, envelope: str) -> str:
|
|
103
|
+
return self.decrypt(envelope).decode()
|
|
104
|
+
|
|
105
|
+
def is_envelope(self, s: str | None) -> bool:
|
|
106
|
+
return bool(s) and s.startswith(self.PREFIX) and s.endswith("»")
|
|
107
|
+
|
|
108
|
+
# -------------------------------------------------------------- rotation
|
|
109
|
+
def rotate(self, new_master_key: bytes) -> bytes:
|
|
110
|
+
"""Re-wrap the DEK under a new master key. Returns the DEK so the
|
|
111
|
+
caller can construct a new cipher bound to the same store."""
|
|
112
|
+
if len(new_master_key) != 32:
|
|
113
|
+
raise ValueError("master key must be 32 bytes")
|
|
114
|
+
if self._store is None:
|
|
115
|
+
raise ValueError("rotation requires a store-bound cipher")
|
|
116
|
+
nonce = secrets.token_bytes(12)
|
|
117
|
+
wrapped = AESGCM(new_master_key).encrypt(nonce, self._dek, b"context-m-dek")
|
|
118
|
+
self._store.kv_set("enc:dek", f"{self.FORMAT}:{_b64e(nonce + wrapped)}")
|
|
119
|
+
return self._dek
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------------ helpers
|
|
123
|
+
def load_master_key(path: str | None = None,
|
|
124
|
+
env: str = ENV_MASTER_KEY) -> bytes | None:
|
|
125
|
+
"""Master key resolution order: explicit path, env var (hex/base64),
|
|
126
|
+
sidecar ``<db>.key``. Returns None when no key is configured."""
|
|
127
|
+
if path and os.path.exists(path):
|
|
128
|
+
data = open(path, "rb").read().strip()
|
|
129
|
+
return _coerce_key(data)
|
|
130
|
+
val = os.environ.get(env)
|
|
131
|
+
if val:
|
|
132
|
+
return _coerce_key(val.encode().strip())
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _coerce_key(data: bytes) -> bytes:
|
|
137
|
+
if len(data) == 32:
|
|
138
|
+
return data
|
|
139
|
+
try:
|
|
140
|
+
raw = _b64d(data.decode())
|
|
141
|
+
if len(raw) == 32:
|
|
142
|
+
return raw
|
|
143
|
+
except Exception:
|
|
144
|
+
pass
|
|
145
|
+
try:
|
|
146
|
+
raw = bytes.fromhex(data.decode())
|
|
147
|
+
if len(raw) == 32:
|
|
148
|
+
return raw
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
# derive a stable key from arbitrary passphrase material (HKDF-lite)
|
|
152
|
+
import hashlib
|
|
153
|
+
return hashlib.blake2b(data, digest_size=32).digest()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def generate_master_key(path: str) -> bytes:
|
|
157
|
+
"""Generate a new master key and persist it 0600 (operator flow)."""
|
|
158
|
+
key = secrets.token_bytes(32)
|
|
159
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
160
|
+
with os.fdopen(fd, "wb") as fh:
|
|
161
|
+
fh.write(_b64e(key).encode())
|
|
162
|
+
return key
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Cryptographic primitives: BLAKE3 hashing, Merkle trees, attestations.
|
|
2
|
+
|
|
3
|
+
Spec requirement (Section 1.1 / InjecMEM defense): every fact carries a
|
|
4
|
+
BLAKE3 hash of its source text; on retrieval the hash is re-verified, and
|
|
5
|
+
Memory-Git commits form a tamper-evident hash chain. BLAKE2b-256 is the
|
|
6
|
+
automatic fallback when the optional ``blake3`` wheel is absent — the
|
|
7
|
+
provider name is always reported so audits stay honest.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
try: # optional dependency, preferred per spec
|
|
18
|
+
import blake3 as _blake3
|
|
19
|
+
|
|
20
|
+
_HAS_BLAKE3 = True
|
|
21
|
+
except Exception: # pragma: no cover - environment dependent
|
|
22
|
+
_blake3 = None
|
|
23
|
+
_HAS_BLAKE3 = False
|
|
24
|
+
|
|
25
|
+
_log = logging.getLogger("cortexm.security")
|
|
26
|
+
_DOWNGRADE_WARNED = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def has_blake3() -> bool:
|
|
30
|
+
return _HAS_BLAKE3
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _warn_downgrade() -> None:
|
|
34
|
+
"""Loud, once-per-process warning when BLAKE3 is unavailable.
|
|
35
|
+
|
|
36
|
+
Silent capability downgrade is a credibility bug: the docs promise
|
|
37
|
+
BLAKE3 and the user must know when they are actually getting
|
|
38
|
+
BLAKE2b. Install with ``pip install context-m[blake3]`` to silence.
|
|
39
|
+
"""
|
|
40
|
+
global _DOWNGRADE_WARNED
|
|
41
|
+
if not _DOWNGRADE_WARNED:
|
|
42
|
+
_DOWNGRADE_WARNED = True
|
|
43
|
+
_log.warning(
|
|
44
|
+
"blake3 wheel not installed — downgrading hash provider to "
|
|
45
|
+
"BLAKE2b-256. Integrity guarantees hold (collision resistance "
|
|
46
|
+
"is equivalent at 256-bit) but this differs from the documented "
|
|
47
|
+
"default. Fix: pip install 'context-m[blake3]'. "
|
|
48
|
+
"The active provider is always reported in stats() and audit "
|
|
49
|
+
"output as hash_provider."
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class HashProvider:
|
|
54
|
+
"""BLAKE3-256 (preferred) or BLAKE2b-256 (fallback). Same interface."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, algo: str = "blake3") -> None:
|
|
57
|
+
if algo == "blake3" and not _HAS_BLAKE3:
|
|
58
|
+
_warn_downgrade()
|
|
59
|
+
algo = "blake2b"
|
|
60
|
+
if algo not in ("blake3", "blake2b"):
|
|
61
|
+
raise ValueError(f"unsupported hash provider {algo!r}")
|
|
62
|
+
self.algo = algo
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def name(self) -> str:
|
|
66
|
+
return "blake3-256" if self.algo == "blake3" else "blake2b-256"
|
|
67
|
+
|
|
68
|
+
def hash_bytes(self, data: bytes) -> str:
|
|
69
|
+
if self.algo == "blake3":
|
|
70
|
+
return _blake3.blake3(data).hexdigest()
|
|
71
|
+
return hashlib.blake2b(data, digest_size=32).hexdigest()
|
|
72
|
+
|
|
73
|
+
def hash_text(self, text: str) -> str:
|
|
74
|
+
return self.hash_bytes(text.encode("utf-8"))
|
|
75
|
+
|
|
76
|
+
def hash_json(self, obj) -> str:
|
|
77
|
+
payload = json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str)
|
|
78
|
+
return self.hash_text(payload)
|
|
79
|
+
|
|
80
|
+
def short(self, hexdigest: str, n: int = 8) -> str:
|
|
81
|
+
return hexdigest[:n]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# --------------------------------------------------------------------------
|
|
85
|
+
# Merkle trees (binary, duplicate-last-when-odd) — used by ZK-lite proofs.
|
|
86
|
+
# --------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def _h_pair(provider: HashProvider, a: str, b: str) -> str:
|
|
89
|
+
if len(a) == 64 and len(b) == 64:
|
|
90
|
+
try:
|
|
91
|
+
return provider.hash_bytes(bytes.fromhex(a) + bytes.fromhex(b))
|
|
92
|
+
except ValueError:
|
|
93
|
+
pass
|
|
94
|
+
return provider.hash_text(a + b)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def merkle_root(provider: HashProvider, leaves: list[str]) -> str:
|
|
98
|
+
if not leaves:
|
|
99
|
+
return provider.hash_text("empty")
|
|
100
|
+
level = list(leaves)
|
|
101
|
+
while len(level) > 1:
|
|
102
|
+
if len(level) % 2:
|
|
103
|
+
level.append(level[-1])
|
|
104
|
+
level = [_h_pair(provider, level[i], level[i + 1]) for i in range(0, len(level), 2)]
|
|
105
|
+
return level[0]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def merkle_proof(provider: HashProvider, leaves: list[str], index: int) -> tuple[str, list[dict]]:
|
|
109
|
+
"""Return (root, path) where path = [{side, hash}, ...] for leaves[index]."""
|
|
110
|
+
if not leaves or not (0 <= index < len(leaves)):
|
|
111
|
+
raise IndexError("index out of range")
|
|
112
|
+
level = list(leaves)
|
|
113
|
+
idx = index
|
|
114
|
+
path: list[dict] = []
|
|
115
|
+
while len(level) > 1:
|
|
116
|
+
if len(level) % 2:
|
|
117
|
+
level.append(level[-1])
|
|
118
|
+
sib = idx ^ 1
|
|
119
|
+
path.append({"side": "right" if sib > idx else "left", "hash": level[sib]})
|
|
120
|
+
level = [_h_pair(provider, level[i], level[i + 1]) for i in range(0, len(level), 2)]
|
|
121
|
+
idx //= 2
|
|
122
|
+
return level[0], path
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def merkle_verify(provider: HashProvider, leaf: str, path: list[dict], root: str) -> bool:
|
|
126
|
+
cur = leaf
|
|
127
|
+
for step in path:
|
|
128
|
+
if step["side"] == "right":
|
|
129
|
+
cur = _h_pair(provider, cur, step["hash"])
|
|
130
|
+
else:
|
|
131
|
+
cur = _h_pair(provider, step["hash"], cur)
|
|
132
|
+
return hmac.compare_digest(cur, root)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def attest(provider: HashProvider, key: bytes, message: str) -> str:
|
|
136
|
+
return hmac.new(key, message.encode("utf-8"), hashlib.sha256).hexdigest()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def verify_attest(provider: HashProvider, key: bytes, message: str, tag: str) -> bool:
|
|
140
|
+
return hmac.compare_digest(attest(provider, key, message), tag)
|