json-chamber 1.4.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.
@@ -0,0 +1,61 @@
1
+ """json-chamber — JSON sealing protocol.
2
+
3
+ Cloak (seal new JSON) requires a live cloak license.
4
+ Open of an already-sealed blob is keys-only: no license, no clock.
5
+ TruGame and other engines still use require_alive() as a running-engine gate.
6
+ """
7
+
8
+ from .api import cloak_json, open_json, cloak_bytes, open_bytes
9
+ from .core import WORDLIST
10
+ from .license import (
11
+ LicenseError,
12
+ PRODUCTS,
13
+ apply_entitlement,
14
+ create_entitlement,
15
+ license_status,
16
+ require_alive,
17
+ require_cloak,
18
+ reset_for_testing,
19
+ verifieddr_check,
20
+ verify_entitlement,
21
+ )
22
+ from .benefit import (
23
+ benefit_check,
24
+ tru8_benefit_check,
25
+ group_then_pack,
26
+ group_then_compress,
27
+ delta_transform_positions,
28
+ delta_transform_bytes,
29
+ chunk_dedup,
30
+ benefit_report,
31
+ )
32
+
33
+ __all__ = [
34
+ # Sealing
35
+ "cloak_json",
36
+ "open_json",
37
+ "cloak_bytes",
38
+ "open_bytes",
39
+ "WORDLIST",
40
+ # Shared Black Box control plane
41
+ "LicenseError",
42
+ "PRODUCTS",
43
+ "require_alive",
44
+ "require_cloak",
45
+ "license_status",
46
+ "apply_entitlement",
47
+ "create_entitlement",
48
+ "verify_entitlement",
49
+ "verifieddr_check",
50
+ "reset_for_testing",
51
+ # Optimizer / benefit
52
+ "benefit_check",
53
+ "tru8_benefit_check",
54
+ "group_then_pack",
55
+ "group_then_compress",
56
+ "delta_transform_positions",
57
+ "delta_transform_bytes",
58
+ "chunk_dedup",
59
+ "benefit_report",
60
+ ]
61
+ __version__ = "1.4.0"
json_chamber/api.py ADDED
@@ -0,0 +1,46 @@
1
+ """
2
+ High-level and low-level Chamber APIs.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import json
9
+ import os
10
+ from typing import Any
11
+
12
+ from . import core
13
+ from .license import require_cloak
14
+
15
+
16
+ def _master() -> bytes:
17
+ raw = (
18
+ os.environ.get("CHAMBER_MASTER_SECRET")
19
+ or os.environ.get("JSON_CHAMBER_MASTER")
20
+ or os.environ.get("TRU8_MASTER_SECRET", "chamber-demo-master-secret-32b!!")
21
+ )
22
+ b = raw.encode("utf-8")
23
+ if len(b) < 32:
24
+ b = hashlib.sha256(b).digest()
25
+ return b
26
+
27
+
28
+ def cloak_bytes(data: bytes) -> dict:
29
+ """Seal bytes. Requires a live cloak license (24h try, then month/year)."""
30
+ require_cloak("json-chamber")
31
+ return core.seal_aont(data, _master())
32
+
33
+
34
+ def open_bytes(sealed: dict) -> bytes:
35
+ """Open a sealed blob. Keys only — no license, no clock, no kill switch."""
36
+ return core.open_sealed(sealed, _master())
37
+
38
+
39
+ def cloak_json(obj: Any) -> dict:
40
+ raw = json.dumps(obj, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
41
+ return cloak_bytes(raw)
42
+
43
+
44
+ def open_json(sealed: dict) -> Any:
45
+ raw = open_bytes(sealed)
46
+ return json.loads(raw.decode("utf-8"))
@@ -0,0 +1,289 @@
1
+ """
2
+ benefit.py — public-safe optimizer for the $99 → $1,900 funnel.
3
+
4
+ NO TRU8 residual engine here.
5
+ • benefit_check / tru8_benefit_check – entropy + zero-bit bias + pair entropy
6
+ • group_then_pack / group_then_compress – create structure across tiny files
7
+ • delta_transform_positions – mesh/position structure before coding
8
+ • chunk_dedup – cross-file repeat report (analysis only)
9
+
10
+ $99 path (json-chamber) always works via cloak_json / cloak_bytes.
11
+ $1,900 path is only advertised when compress=True.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import io
18
+ import math
19
+ import struct
20
+ import tarfile
21
+ import zlib
22
+ from collections import Counter
23
+ from typing import Iterable, List, Mapping, Sequence, Tuple, Union
24
+
25
+
26
+ def _undress(data: bytes) -> List[int]:
27
+ bits: List[int] = []
28
+ for b in data:
29
+ for i in range(7, -1, -1):
30
+ bits.append((b >> i) & 1)
31
+ return bits
32
+
33
+
34
+ def shannon_entropy(data: bytes) -> float:
35
+ if not data:
36
+ return 0.0
37
+ n = len(data)
38
+ counts = Counter(data)
39
+ return -sum((v / n) * math.log2(v / n) for v in counts.values())
40
+
41
+
42
+ def _pair_entropy(bits: List[int]) -> float:
43
+ if len(bits) < 2:
44
+ return 0.0
45
+ pairs = [(bits[i], bits[i + 1]) for i in range(0, len(bits) - 1)]
46
+ n = len(pairs)
47
+ c = Counter(pairs)
48
+ return -sum((v / n) * math.log2(v / n) for v in c.values())
49
+
50
+
51
+ def benefit_check(data: bytes, *, min_bytes: int = 1024) -> dict:
52
+ if data is None or len(data) == 0:
53
+ return {
54
+ "compress": False,
55
+ "reason": "empty payload",
56
+ "action": "json-chamber",
57
+ "recommend": "json-chamber",
58
+ "entropy": 0.0,
59
+ "bias": 0.5,
60
+ "pair_entropy": 0.0,
61
+ "size": 0,
62
+ "est_saving": 0.0,
63
+ "price_hint": 99,
64
+ }
65
+
66
+ n = len(data)
67
+ if n < min_bytes:
68
+ return {
69
+ "compress": False,
70
+ "reason": f"tiny <{min_bytes}B - use json-chamber only ($99 tier); or group_then_pack first",
71
+ "action": "json-chamber",
72
+ "recommend": "group-first",
73
+ "entropy": shannon_entropy(data),
74
+ "bias": 0.5,
75
+ "pair_entropy": 0.0,
76
+ "size": n,
77
+ "est_saving": 0.0,
78
+ "price_hint": 99,
79
+ }
80
+
81
+ entropy = shannon_entropy(data)
82
+ sample = data[:8192]
83
+ bits = _undress(sample)
84
+ zeros = bits.count(0)
85
+ bias = (zeros / len(bits)) if bits else 0.5
86
+ pair_entropy = _pair_entropy(bits)
87
+
88
+ if entropy > 7.8:
89
+ return {
90
+ "compress": False,
91
+ "reason": f"high entropy {entropy:.2f}/8.0 - already compressed/encrypted/random",
92
+ "action": "json-chamber only + chunk dedup",
93
+ "recommend": "json-chamber",
94
+ "entropy": round(entropy, 4),
95
+ "bias": round(bias, 4),
96
+ "pair_entropy": round(pair_entropy, 4),
97
+ "size": n,
98
+ "est_saving": 0.0,
99
+ "price_hint": 99,
100
+ }
101
+
102
+ est_saving = max(0.0, (8.0 - entropy) / 8.0)
103
+ bias_boost = abs(bias - 0.5) * 0.4
104
+ est_saving = min(0.95, est_saving + bias_boost)
105
+
106
+ if est_saving < 0.08 and entropy > 7.0:
107
+ return {
108
+ "compress": False,
109
+ "reason": f"low structure - entropy {entropy:.2f}, bias {bias:.3f}, est {est_saving*100:.1f}%",
110
+ "action": "json-chamber",
111
+ "recommend": "json-chamber",
112
+ "entropy": round(entropy, 4),
113
+ "bias": round(bias, 4),
114
+ "pair_entropy": round(pair_entropy, 4),
115
+ "size": n,
116
+ "est_saving": round(est_saving, 4),
117
+ "price_hint": 99,
118
+ }
119
+
120
+ return {
121
+ "compress": True,
122
+ "reason": (
123
+ f"good candidate - entropy {entropy:.2f}, bias {bias:.3f}, "
124
+ f"est {est_saving*100:.1f}% saving"
125
+ ),
126
+ "action": "tru8-chamber $1900 tier",
127
+ "recommend": "tru8-chamber",
128
+ "entropy": round(entropy, 4),
129
+ "bias": round(bias, 4),
130
+ "pair_entropy": round(pair_entropy, 4),
131
+ "size": n,
132
+ "est_saving": round(est_saving, 4),
133
+ "price_hint": 1900,
134
+ "note": "TRU8 engine is proprietary — contact license@slidphilabs.com",
135
+ }
136
+
137
+
138
+ tru8_benefit_check = benefit_check
139
+
140
+
141
+ def delta_transform_positions(
142
+ positions: Sequence[float],
143
+ *,
144
+ quantize: float = 1e-4,
145
+ ) -> bytes:
146
+ if not positions:
147
+ return b""
148
+ quantized = [int(round(p / quantize)) for p in positions]
149
+ deltas = [quantized[0]]
150
+ for i in range(1, len(quantized)):
151
+ deltas.append(quantized[i] - quantized[i - 1])
152
+ out = io.BytesIO()
153
+ for d in deltas:
154
+ zz = (d << 1) ^ (d >> 63) if d < 0 else (d << 1)
155
+ while zz >= 0x80:
156
+ out.write(bytes([(zz & 0x7F) | 0x80]))
157
+ zz >>= 7
158
+ out.write(bytes([zz & 0x7F]))
159
+ return out.getvalue()
160
+
161
+
162
+ def delta_transform_bytes(raw: bytes, *, stride: int = 4, fmt: str = "<f") -> bytes:
163
+ size = struct.calcsize(fmt)
164
+ n = len(raw) // size
165
+ if n == 0:
166
+ return raw
167
+ vals = list(struct.unpack("%d%s" % (n, fmt[-1] if fmt[-1] in "fd" else "f"), raw[: n * size]))
168
+ return delta_transform_positions(vals)
169
+
170
+
171
+ def group_then_pack(
172
+ files: Union[Mapping[str, bytes], Sequence[Tuple[str, bytes]]],
173
+ *,
174
+ mode: str = "tar",
175
+ ) -> bytes:
176
+ if isinstance(files, Mapping):
177
+ items = list(files.items())
178
+ else:
179
+ items = list(files)
180
+ if mode == "tar":
181
+ buf = io.BytesIO()
182
+ with tarfile.open(fileobj=buf, mode="w") as tar:
183
+ for name, payload in items:
184
+ info = tarfile.TarInfo(name=str(name).replace("\\", "/").lstrip("/"))
185
+ info.size = len(payload)
186
+ tar.addfile(info, io.BytesIO(payload))
187
+ return buf.getvalue()
188
+ buf = io.BytesIO()
189
+ for name, data in items:
190
+ name_b = str(name).encode("utf-8")
191
+ buf.write(struct.pack("<I", len(name_b)))
192
+ buf.write(name_b)
193
+ buf.write(struct.pack("<I", len(data)))
194
+ buf.write(data)
195
+ return buf.getvalue()
196
+
197
+
198
+ def group_then_compress(
199
+ files: Union[Mapping[str, bytes], Sequence[Tuple[str, bytes]]],
200
+ ) -> dict:
201
+ if isinstance(files, Mapping):
202
+ items = list(files.items())
203
+ else:
204
+ items = list(files)
205
+ total_raw = sum(len(d) for _, d in items)
206
+ grouped_bytes = group_then_pack(items, mode="tar")
207
+ check = benefit_check(grouped_bytes)
208
+ compressed = zlib.compress(grouped_bytes, level=9)
209
+ group_saving = 1.0 - (len(compressed) / len(grouped_bytes)) if grouped_bytes else 0.0
210
+ individual_saving = []
211
+ for _, data in items:
212
+ if not data:
213
+ individual_saving.append(0.0)
214
+ continue
215
+ c = zlib.compress(data, level=9)
216
+ individual_saving.append(1.0 - (len(c) / len(data)))
217
+ avg_individual = sum(individual_saving) / len(individual_saving) if individual_saving else 0.0
218
+ return {
219
+ "total_files": len(items),
220
+ "total_raw": total_raw,
221
+ "grouped_raw": len(grouped_bytes),
222
+ "compressed": len(compressed),
223
+ "group_saving": round(group_saving, 4),
224
+ "avg_individual_saving": round(avg_individual, 4),
225
+ "gain_from_grouping": round(group_saving - avg_individual, 4),
226
+ "benefit_check": check,
227
+ "recommendation": (
228
+ f"Grouping created {(group_saving - avg_individual)*100:.1f}% extra saving"
229
+ if group_saving > avg_individual
230
+ else "Grouping did not beat per-file baseline"
231
+ ),
232
+ "note": "zlib baseline only — TRU8 residual engine is proprietary ($1,900 tier)",
233
+ }
234
+
235
+
236
+ def chunk_dedup(files: Sequence[bytes], chunk_size: int = 4096) -> dict:
237
+ seen = {}
238
+ total_chunks = 0
239
+ dup_chunks = 0
240
+ for data in files:
241
+ for i in range(0, len(data), chunk_size):
242
+ chunk = data[i : i + chunk_size]
243
+ h = hashlib.blake2b(chunk, digest_size=16).hexdigest()
244
+ total_chunks += 1
245
+ if h in seen:
246
+ dup_chunks += 1
247
+ else:
248
+ seen[h] = True
249
+ dedup_ratio = (dup_chunks / total_chunks) if total_chunks else 0.0
250
+ return {
251
+ "total_chunks": total_chunks,
252
+ "unique_chunks": len(seen),
253
+ "dup_chunks": dup_chunks,
254
+ "dedup_saving": round(dedup_ratio, 4),
255
+ "reason": (
256
+ f"{dup_chunks}/{total_chunks} chunks duplicate - "
257
+ f"{dedup_ratio*100:.1f}% saving via dedup even if entropy high"
258
+ ),
259
+ "action": "use chunk dedup + json-chamber for high entropy bundles",
260
+ "note": "analysis only — production dedup packer is $1,900 tier",
261
+ }
262
+
263
+
264
+ def benefit_report(*blobs: bytes, names: Sequence[str] | None = None) -> dict:
265
+ per_file = []
266
+ for i, b in enumerate(blobs):
267
+ label = names[i] if names and i < len(names) else f"file_{i}"
268
+ r = benefit_check(b)
269
+ r["name"] = label
270
+ per_file.append(r)
271
+ grouped = None
272
+ if len(blobs) >= 2:
273
+ packed = group_then_pack({r["name"]: blobs[i] for i, r in enumerate(per_file)})
274
+ grouped = benefit_check(packed)
275
+ grouped["name"] = "__grouped__"
276
+ grouped["packed_bytes"] = len(packed)
277
+ any_compress = any(r.get("compress") for r in per_file) or (
278
+ grouped.get("compress") if grouped else False
279
+ )
280
+ return {
281
+ "files": per_file,
282
+ "grouped": grouped,
283
+ "any_compress_candidate": bool(any_compress),
284
+ "funnel": (
285
+ "tru8-chamber ($1,900/project/year)"
286
+ if any_compress
287
+ else "json-chamber ($99) — seal only"
288
+ ),
289
+ }
json_chamber/core.py ADDED
@@ -0,0 +1,200 @@
1
+ """
2
+ Chamber core – φ-split + keyword lock.
3
+
4
+ A 32-byte session key is derived, then split with a simple 2-of-2
5
+ φ-inspired share (XOR + golden-ratio bit rotation) into two 16-byte
6
+ halves. Each half is rendered as a space-separated word list drawn
7
+ from a curated chamber vocabulary. The sealed object ships both
8
+ word lists + nonce + tag + ciphertext; neither list alone can
9
+ reconstruct the key. Opening requires both lists + the master secret
10
+ (used as an additional KDF salt) + a live license.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import hmac
17
+ import os
18
+ from typing import Sequence
19
+
20
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
21
+
22
+ # Curated chamber vocabulary (64 words → 6 bits each, clean encoding)
23
+ _WORDS: tuple[str, ...] = (
24
+ "ward", "veil", "spire", "lock", "oath", "cloak", "shard", "mark",
25
+ "rune", "gate", "seal", "vault", "cipher", "glyph", "prism", "echo",
26
+ "aegis", "sigil", "forge", "anchor", "tide", "ember", "frost", "bloom",
27
+ "thorn", "quill", "mirror", "hollow", "crown", "blade", "root", "star",
28
+ "mist", "iron", "glass", "stone", "flame", "shade", "pulse", "nexus",
29
+ "orbit", "vector", "lattice", "helix", "core", "rim", "axis", "node",
30
+ "flux", "phase", "drift", "spark", "void", "bound", "link", "key",
31
+ "guard", "watch", "keep", "hold", "bind", "cast", "form", "rise",
32
+ )
33
+
34
+ assert len(_WORDS) == 64
35
+ WORDLIST = _WORDS
36
+
37
+ # φ ≈ 1.6180339887 – used for a deterministic bit rotation amount
38
+ _PHI_ROT = 21 # round(32 * (φ - 1)) ≈ 19.4 → pick 21 for nice mix
39
+
40
+
41
+ def _bytes_to_words(data: bytes) -> str:
42
+ """Encode exact bytes as space-separated words (6 bits per word)."""
43
+ pad = (3 - len(data) % 3) % 3
44
+ buf = data + b"\x00" * pad
45
+ words: list[str] = []
46
+ for i in range(0, len(buf), 3):
47
+ n = (buf[i] << 16) | (buf[i + 1] << 8) | buf[i + 2]
48
+ for shift in (18, 12, 6, 0):
49
+ words.append(_WORDS[(n >> shift) & 0x3F])
50
+ return " ".join(words)
51
+
52
+
53
+ def _words_to_bytes(text: str, expected_len: int) -> bytes:
54
+ """Decode word list back to bytes, truncate to expected_len."""
55
+ tokens = text.strip().lower().split()
56
+ idx = {_WORDS[i]: i for i in range(64)}
57
+ out = bytearray()
58
+ for i in range(0, len(tokens), 4):
59
+ chunk = tokens[i : i + 4]
60
+ if len(chunk) < 4:
61
+ break
62
+ n = 0
63
+ for w in chunk:
64
+ if w not in idx:
65
+ raise ValueError(f"unknown chamber word: {w!r}")
66
+ n = (n << 6) | idx[w]
67
+ out.append((n >> 16) & 0xFF)
68
+ out.append((n >> 8) & 0xFF)
69
+ out.append(n & 0xFF)
70
+ return bytes(out[:expected_len])
71
+
72
+
73
+ def _phi_split(key32: bytes) -> tuple[bytes, bytes]:
74
+ """
75
+ 2-of-2 φ-split.
76
+ k = first 16 bytes rotated by φ amount
77
+ r = second 16 bytes XOR k
78
+ Reconstruct: r XOR k → second half, un-rotate → full key.
79
+ """
80
+ if len(key32) != 32:
81
+ raise ValueError("key must be 32 bytes")
82
+ left = bytearray(key32[:16])
83
+ right = bytearray(key32[16:])
84
+ bits = int.from_bytes(left, "big")
85
+ rot = ((bits << _PHI_ROT) | (bits >> (128 - _PHI_ROT))) & ((1 << 128) - 1)
86
+ k = rot.to_bytes(16, "big")
87
+ r = bytes(a ^ b for a, b in zip(right, k))
88
+ return k, r
89
+
90
+
91
+ def _phi_join(k: bytes, r: bytes) -> bytes:
92
+ if len(k) != 16 or len(r) != 16:
93
+ raise ValueError("shares must be 16 bytes each")
94
+ right = bytes(a ^ b for a, b in zip(r, k))
95
+ bits = int.from_bytes(k, "big")
96
+ unrot = ((bits >> _PHI_ROT) | (bits << (128 - _PHI_ROT))) & ((1 << 128) - 1)
97
+ left = unrot.to_bytes(16, "big")
98
+ return left + right
99
+
100
+
101
+ def _derive_session_key(master: bytes, salt: bytes = b"chamber-phi-v1") -> bytes:
102
+ return hashlib.pbkdf2_hmac("sha256", master, salt, iterations=120_000, dklen=32)
103
+
104
+
105
+ def _bind_key(msg_key: bytes, ct: bytes) -> bytes:
106
+ """AONT bind: message key XOR repeating SHA-256 of ciphertext."""
107
+ h = hashlib.sha256(ct).digest()
108
+ return bytes(msg_key[i] ^ h[i % 32] for i in range(32))
109
+
110
+
111
+ def seal(plaintext: bytes, master: bytes) -> dict:
112
+ """v1 seal — both word lists required. Kept so already-sealed blobs still open."""
113
+ session = _derive_session_key(master)
114
+ msg_key = os.urandom(32)
115
+ k, r = _phi_split(msg_key)
116
+
117
+ aesgcm = AESGCM(msg_key)
118
+ nonce = os.urandom(12)
119
+ ct = aesgcm.encrypt(nonce, plaintext, associated_data=b"chamber-v1")
120
+
121
+ tag = hashlib.sha256(k + r + nonce).digest()[:16]
122
+ _ = session
123
+
124
+ return {
125
+ "v": 1,
126
+ "algo": "chamber-aes256gcm-phi",
127
+ "k_words": _bytes_to_words(k),
128
+ "r_words": _bytes_to_words(r),
129
+ "nonce": nonce.hex(),
130
+ "tag": tag.hex(),
131
+ "ct": ct.hex(),
132
+ "orig_len": len(plaintext),
133
+ }
134
+
135
+
136
+ def seal_aont(plaintext: bytes, master: bytes) -> dict:
137
+ """v2: encrypt, bind the key to the ciphertext, then φ-split the bound key.
138
+
139
+ One share plus the blob is not enough to recover the message key.
140
+ Lab-owned AONT, not a Shamir library wrap.
141
+ """
142
+ _ = _derive_session_key(master)
143
+ msg_key = os.urandom(32)
144
+ aesgcm = AESGCM(msg_key)
145
+ nonce = os.urandom(12)
146
+ ct = aesgcm.encrypt(nonce, plaintext, associated_data=b"chamber-v2")
147
+ package_key = _bind_key(msg_key, ct)
148
+ k, r = _phi_split(package_key)
149
+ tag = hashlib.sha256(k + r + nonce + ct[:16]).digest()[:16]
150
+ return {
151
+ "v": 2,
152
+ "algo": "chamber-aes256gcm-phi-aont",
153
+ "k_words": _bytes_to_words(k),
154
+ "r_words": _bytes_to_words(r),
155
+ "nonce": nonce.hex(),
156
+ "tag": tag.hex(),
157
+ "ct": ct.hex(),
158
+ "orig_len": len(plaintext),
159
+ }
160
+
161
+
162
+ def open_sealed(sealed: dict, master: bytes) -> bytes:
163
+ """Reconstruct key from both word lists and decrypt. v1 and v2."""
164
+ ver = sealed.get("v")
165
+ if ver == 1:
166
+ if sealed.get("algo") != "chamber-aes256gcm-phi":
167
+ raise ValueError("algo mismatch")
168
+ k = _words_to_bytes(sealed["k_words"], 16)
169
+ r = _words_to_bytes(sealed["r_words"], 16)
170
+ nonce = bytes.fromhex(sealed["nonce"])
171
+ expected_tag = hashlib.sha256(k + r + nonce).digest()[:16]
172
+ if not hmac_compare(expected_tag, bytes.fromhex(sealed["tag"])):
173
+ raise ValueError("share integrity tag mismatch – possible tamper")
174
+ msg_key = _phi_join(k, r)
175
+ _ = _derive_session_key(master)
176
+ aesgcm = AESGCM(msg_key)
177
+ ct = bytes.fromhex(sealed["ct"])
178
+ return aesgcm.decrypt(nonce, ct, associated_data=b"chamber-v1")
179
+ if ver == 2:
180
+ if sealed.get("algo") != "chamber-aes256gcm-phi-aont":
181
+ raise ValueError("algo mismatch")
182
+ k = _words_to_bytes(sealed["k_words"], 16)
183
+ r = _words_to_bytes(sealed["r_words"], 16)
184
+ nonce = bytes.fromhex(sealed["nonce"])
185
+ ct = bytes.fromhex(sealed["ct"])
186
+ expected_tag = hashlib.sha256(k + r + nonce + ct[:16]).digest()[:16]
187
+ if not hmac_compare(expected_tag, bytes.fromhex(sealed["tag"])):
188
+ raise ValueError("share integrity tag mismatch – possible tamper")
189
+ package_key = _phi_join(k, r)
190
+ msg_key = _bind_key(package_key, ct)
191
+ _ = _derive_session_key(master)
192
+ aesgcm = AESGCM(msg_key)
193
+ return aesgcm.decrypt(nonce, ct, associated_data=b"chamber-v2")
194
+ raise ValueError(f"unsupported chamber version: {ver}")
195
+
196
+
197
+ def hmac_compare(a: bytes, b: bytes) -> bool:
198
+ if len(a) != len(b):
199
+ return False
200
+ return hmac.compare_digest(a, b)