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.
Files changed (120) hide show
  1. context_m.py +17 -0
  2. cortexm/__init__.py +45 -0
  3. cortexm/accel.py +403 -0
  4. cortexm/api/__init__.py +0 -0
  5. cortexm/api/chaos.py +118 -0
  6. cortexm/api/memory.py +635 -0
  7. cortexm/bench/__init__.py +0 -0
  8. cortexm/bench/abilities.py +311 -0
  9. cortexm/bench/baselines.py +89 -0
  10. cortexm/bench/beam_loader.py +317 -0
  11. cortexm/bench/generator.py +376 -0
  12. cortexm/bench/harness.py +211 -0
  13. cortexm/bench/messy.py +218 -0
  14. cortexm/bench/micro.py +251 -0
  15. cortexm/bench/ood.py +443 -0
  16. cortexm/bench/run.py +137 -0
  17. cortexm/bridge/__init__.py +0 -0
  18. cortexm/bridge/dates.py +178 -0
  19. cortexm/bridge/decoders.py +204 -0
  20. cortexm/bridge/enrich.py +255 -0
  21. cortexm/bridge/extractor.py +316 -0
  22. cortexm/bridge/fallback.py +332 -0
  23. cortexm/bridge/onnx_runtime.py +158 -0
  24. cortexm/bridge/patterns.py +760 -0
  25. cortexm/bridge/ppr.py +104 -0
  26. cortexm/bridge/prefilter.py +188 -0
  27. cortexm/bridge/query_extract.py +420 -0
  28. cortexm/bridge/reader.py +1174 -0
  29. cortexm/bridge/rerank.py +204 -0
  30. cortexm/bridge/writer.py +492 -0
  31. cortexm/cli.py +295 -0
  32. cortexm/cognition/__init__.py +53 -0
  33. cortexm/cognition/abstraction.py +192 -0
  34. cortexm/cognition/analogy.py +159 -0
  35. cortexm/cognition/engine.py +204 -0
  36. cortexm/cognition/gaps.py +365 -0
  37. cortexm/cognition/scanner.py +204 -0
  38. cortexm/config.py +375 -0
  39. cortexm/cortexm.py +8 -0
  40. cortexm/enterprise/__init__.py +0 -0
  41. cortexm/enterprise/audit.py +178 -0
  42. cortexm/enterprise/governance.py +239 -0
  43. cortexm/errors.py +35 -0
  44. cortexm/features/__init__.py +0 -0
  45. cortexm/features/git.py +204 -0
  46. cortexm/features/prefetch.py +88 -0
  47. cortexm/features/zk.py +105 -0
  48. cortexm/federation/__init__.py +39 -0
  49. cortexm/federation/crdt.py +275 -0
  50. cortexm/federation/fabric.py +109 -0
  51. cortexm/federation/hlc.py +80 -0
  52. cortexm/federation/node.py +145 -0
  53. cortexm/federation/schema_report.py +73 -0
  54. cortexm/federation/transport.py +164 -0
  55. cortexm/index/__init__.py +19 -0
  56. cortexm/index/nsg.py +386 -0
  57. cortexm/mcp/__init__.py +0 -0
  58. cortexm/mcp/server.py +985 -0
  59. cortexm/metrics.py +62 -0
  60. cortexm/migrate/__init__.py +0 -0
  61. cortexm/migrate/importers.py +192 -0
  62. cortexm/provenance/__init__.py +78 -0
  63. cortexm/provenance/agent.py +214 -0
  64. cortexm/provenance/cose.py +201 -0
  65. cortexm/provenance/scitt.py +258 -0
  66. cortexm/provenance/vc.py +250 -0
  67. cortexm/security/__init__.py +0 -0
  68. cortexm/security/crypto.py +162 -0
  69. cortexm/security/hashes.py +140 -0
  70. cortexm/security/injection.py +149 -0
  71. cortexm/security/mind.py +154 -0
  72. cortexm/security/pii.py +265 -0
  73. cortexm/security/rbac.py +169 -0
  74. cortexm/security/sandbox.py +131 -0
  75. cortexm/security/zk_hamming.py +142 -0
  76. cortexm/security/zk_sql.py +485 -0
  77. cortexm/server/__init__.py +0 -0
  78. cortexm/server/metrics.py +88 -0
  79. cortexm/server/rest.py +936 -0
  80. cortexm/server/sparql.py +984 -0
  81. cortexm/text/__init__.py +0 -0
  82. cortexm/text/dissim.py +252 -0
  83. cortexm/text/embedder.py +155 -0
  84. cortexm/text/fuzzy.py +218 -0
  85. cortexm/text/idiolect.py +253 -0
  86. cortexm/text/labse.py +374 -0
  87. cortexm/text/tokenizer.py +79 -0
  88. cortexm/trace/__init__.py +0 -0
  89. cortexm/trace/blob_arena.py +277 -0
  90. cortexm/trace/consolidate.py +337 -0
  91. cortexm/trace/contradictions.py +69 -0
  92. cortexm/trace/dedup.py +114 -0
  93. cortexm/trace/edges.py +214 -0
  94. cortexm/trace/fact.py +121 -0
  95. cortexm/trace/fade.py +245 -0
  96. cortexm/trace/lifecycle.py +112 -0
  97. cortexm/trace/rebuild.py +173 -0
  98. cortexm/trace/rules.py +171 -0
  99. cortexm/trace/store.py +680 -0
  100. cortexm/trace/structural.py +183 -0
  101. cortexm/trace/tmt.py +335 -0
  102. cortexm/util.py +148 -0
  103. cortexm/vsa/__init__.py +0 -0
  104. cortexm/vsa/attribution.py +149 -0
  105. cortexm/vsa/cleanup.py +161 -0
  106. cortexm/vsa/codecs.py +397 -0
  107. cortexm/vsa/hologram_overlay.py +139 -0
  108. cortexm/vsa/index.py +163 -0
  109. cortexm/vsa/ops.py +149 -0
  110. cortexm/vsa/palace.py +446 -0
  111. cortexm/vsa/role_vectors.py +236 -0
  112. cortexm/vsa/slb.py +78 -0
  113. cortexm/vsa/tlsh_trie.py +137 -0
  114. cortexm/vsa/working_memory.py +249 -0
  115. cortexm-0.3.0.dist-info/METADATA +482 -0
  116. cortexm-0.3.0.dist-info/RECORD +120 -0
  117. cortexm-0.3.0.dist-info/WHEEL +5 -0
  118. cortexm-0.3.0.dist-info/entry_points.txt +2 -0
  119. cortexm-0.3.0.dist-info/licenses/LICENSE +190 -0
  120. cortexm-0.3.0.dist-info/top_level.txt +2 -0
@@ -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)