ocf-containers 1.0.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.
ocf/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """OCF — Open Container Format."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,13 @@
1
+ from .machine import (
2
+ FingerprintResult,
3
+ WEIGHTED_SOURCES,
4
+ collect_components,
5
+ fingerprint_from,
6
+ get_fingerprint,
7
+ get_partial_fingerprint,
8
+ )
9
+
10
+ __all__ = [
11
+ "FingerprintResult", "WEIGHTED_SOURCES", "collect_components",
12
+ "fingerprint_from", "get_fingerprint", "get_partial_fingerprint",
13
+ ]
ocf/binding/machine.py ADDED
@@ -0,0 +1,125 @@
1
+ """Local machine fingerprinting (Windows-first, injectable for tests)."""
2
+
3
+ import hashlib
4
+ import platform
5
+ import subprocess
6
+ import uuid
7
+ from dataclasses import dataclass
8
+
9
+ WEIGHTED_SOURCES: dict[str, int] = {
10
+ "machine_guid": 4,
11
+ "bios_uuid": 3,
12
+ "cpu_id": 2,
13
+ "disk_serial": 2,
14
+ "mac_address": 1,
15
+ }
16
+
17
+ _FP_DOMAIN = b"ocf-mb:v1"
18
+
19
+
20
+ def _ps_first_line(cmd: str) -> str:
21
+ try:
22
+ out = subprocess.run(
23
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", cmd],
24
+ capture_output=True, text=True, timeout=15,
25
+ )
26
+ for line in out.stdout.splitlines():
27
+ line = line.strip()
28
+ if line and line != "SerialNumber":
29
+ return line
30
+ except Exception:
31
+ pass
32
+ return ""
33
+
34
+
35
+ def _collect_machine_guid() -> str:
36
+ if platform.system() != "Windows":
37
+ return ""
38
+ try:
39
+ import winreg
40
+ with winreg.OpenKey(
41
+ winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Microsoft\Cryptography"
42
+ ) as key:
43
+ return str(winreg.QueryValueEx(key, "MachineGuid")[0])
44
+ except Exception:
45
+ return ""
46
+
47
+
48
+ def _collect_bios_uuid() -> str:
49
+ if platform.system() != "Windows":
50
+ return ""
51
+ return _ps_first_line("(Get-CimInstance Win32_ComputerSystemProduct).UUID")
52
+
53
+
54
+ def _collect_cpu_id() -> str:
55
+ if platform.system() != "Windows":
56
+ return platform.processor()
57
+ return _ps_first_line(
58
+ "(Get-CimInstance Win32_Processor | Select-Object -First 1).ProcessorId"
59
+ )
60
+
61
+
62
+ def _collect_disk_serial() -> str:
63
+ if platform.system() != "Windows":
64
+ return ""
65
+ return _ps_first_line(
66
+ "(Get-PhysicalDisk | Select-Object -First 1).SerialNumber"
67
+ )
68
+
69
+
70
+ def _collect_mac_address() -> str:
71
+ node = uuid.getnode()
72
+ return ":".join(f"{(node >> (8 * i)) & 0xFF:02x}" for i in reversed(range(6)))
73
+
74
+
75
+ _COLLECTORS = {
76
+ "machine_guid": _collect_machine_guid,
77
+ "bios_uuid": _collect_bios_uuid,
78
+ "cpu_id": _collect_cpu_id,
79
+ "disk_serial": _collect_disk_serial,
80
+ "mac_address": _collect_mac_address,
81
+ }
82
+
83
+
84
+ def collect_components() -> dict[str, str]:
85
+ return {name: fn().strip() for name, fn in _COLLECTORS.items()}
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class FingerprintResult:
90
+ digest: bytes
91
+ sources: dict[str, str]
92
+ weights: dict[str, int]
93
+
94
+
95
+ def fingerprint_from(
96
+ components: dict[str, str], include: set[str] | None = None
97
+ ) -> FingerprintResult:
98
+ names = [
99
+ n for n in WEIGHTED_SOURCES
100
+ if n in components and components[n].strip()
101
+ and (include is None or n in include)
102
+ ]
103
+ h = hashlib.sha256(_FP_DOMAIN)
104
+ h.update(bytes([len(names)]))
105
+ used, weights = {}, {}
106
+ for n in names:
107
+ value = components[n].strip()
108
+ weight = WEIGHTED_SOURCES[n]
109
+ digest = hashlib.sha256(f"{n}|{value}".encode()).digest()
110
+ h.update(n.encode() + b"\x00" + bytes([weight]) + digest)
111
+ used[n] = value
112
+ weights[n] = weight
113
+ return FingerprintResult(h.digest(), used, weights)
114
+
115
+
116
+ def get_fingerprint(components: dict[str, str] | None = None) -> bytes:
117
+ comps = components if components is not None else collect_components()
118
+ return fingerprint_from(comps).digest
119
+
120
+
121
+ def get_partial_fingerprint(
122
+ include: set[str], components: dict[str, str] | None = None
123
+ ) -> FingerprintResult:
124
+ comps = components if components is not None else collect_components()
125
+ return fingerprint_from(comps, include=include)
@@ -0,0 +1,20 @@
1
+ from .layout import (
2
+ CHUNK_SIZE_DEFAULT, MAGIC_FOOTER, MAGIC_HEADER, SEC_DATA, SEC_END,
3
+ SEC_INDEX, SEC_KEYS, SEC_META, SEC_POLICY,
4
+ )
5
+ from .policy import (
6
+ Policy, PolicyError, check_play_password, enforce_policy, make_policy,
7
+ pack_policy, sign_policy, unpack_policy, verify_policy_signature,
8
+ )
9
+ from .reader import ContainerError, ContainerReader
10
+ from .state import PlayStateStore
11
+ from .writer import ContainerWriter, FileEntry
12
+
13
+ __all__ = [
14
+ "ContainerWriter", "ContainerReader", "ContainerError", "FileEntry",
15
+ "Policy", "PolicyError", "make_policy", "pack_policy", "unpack_policy",
16
+ "sign_policy", "verify_policy_signature", "enforce_policy",
17
+ "check_play_password", "PlayStateStore",
18
+ "MAGIC_HEADER", "MAGIC_FOOTER", "SEC_META", "SEC_KEYS", "SEC_POLICY",
19
+ "SEC_INDEX", "SEC_DATA", "SEC_END", "CHUNK_SIZE_DEFAULT",
20
+ ]
@@ -0,0 +1,60 @@
1
+ import struct
2
+
3
+ MAGIC_HEADER = b"OCF1"
4
+ MAGIC_FOOTER = b"OCFf"
5
+
6
+ HEADER_SIZE = 32
7
+ FOOTER_SIZE = 32
8
+
9
+ SEC_META = 0x0001
10
+ SEC_KEYS = 0x0002
11
+ SEC_INDEX = 0x0003
12
+ SEC_DATA = 0x0004
13
+ SEC_LICENSE = 0x0005
14
+ SEC_POLICY = 0x0006
15
+ SEC_PADDING = 0x00FE
16
+ SEC_END = 0x00FF
17
+
18
+ CRITICAL_BIT = 0x8000
19
+
20
+ CHUNK_SIZE_DEFAULT = 262144
21
+
22
+ KEY_PURPOSE_CONTENT = 1
23
+ KEY_PURPOSE_OPEN = 2
24
+ KEY_PURPOSE_PLAY = 3
25
+
26
+ HEADER_STRUCT = struct.Struct("<4sHHIIQQ")
27
+ FOOTER_STRUCT = struct.Struct("<4sIQ16s")
28
+ TLV_STRUCT = struct.Struct("<HI")
29
+
30
+
31
+ def build_header(section_count: int, part_index: int = 0, flags: int = 0) -> bytes:
32
+ return HEADER_STRUCT.pack(
33
+ MAGIC_HEADER, 1, 0, flags, section_count, part_index, 0
34
+ )
35
+
36
+
37
+ def parse_header(data: bytes) -> dict:
38
+ if len(data) < HEADER_SIZE:
39
+ raise ValueError("header truncated")
40
+ magic, maj, minr, flags, count, part, _ = HEADER_STRUCT.unpack(data[:HEADER_SIZE])
41
+ if magic != MAGIC_HEADER:
42
+ raise ValueError("bad magic")
43
+ if maj != 1:
44
+ raise ValueError(f"unsupported major version {maj}")
45
+ return dict(flags=flags, section_count=count, part_index=part)
46
+
47
+
48
+ def tlv(sec_type: int, payload: bytes) -> bytes:
49
+ return TLV_STRUCT.pack(sec_type, len(payload)) + payload
50
+
51
+
52
+ def iter_tlvs(buf: bytes):
53
+ off = 0
54
+ while off + TLV_STRUCT.size <= len(buf):
55
+ sec_type, length = TLV_STRUCT.unpack_from(buf, off)
56
+ off += TLV_STRUCT.size
57
+ if off + length > len(buf):
58
+ raise ValueError("tlv overruns buffer")
59
+ yield sec_type, buf[off:off + length]
60
+ off += length
@@ -0,0 +1,204 @@
1
+ """SEC_POLICY: access rules + optional Ed25519 policy signature."""
2
+
3
+ import struct
4
+ import time
5
+ from dataclasses import dataclass, field
6
+
7
+ from cryptography.exceptions import InvalidSignature
8
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
9
+ Ed25519PrivateKey,
10
+ Ed25519PublicKey,
11
+ )
12
+
13
+ from ..binding.machine import get_fingerprint
14
+ from ..primitives.kdf import hash_password, verify_password_hash
15
+
16
+ POLICY_VERSION = 1
17
+
18
+ FLAG_OPEN_PW = 0x01
19
+ FLAG_PLAY_PW = 0x02
20
+ FLAG_MACHINE_BIND = 0x04
21
+
22
+ FIXED_LEN = 1 + 1 + 2 + 4 + 8 + 16 + 32 # before variable fields
23
+ SIG_LEN = 64
24
+ PUB_LEN = 32
25
+
26
+
27
+ class PolicyError(Exception):
28
+ pass
29
+
30
+
31
+ @dataclass
32
+ class Policy:
33
+ open_password_required: bool = False
34
+ play_password_required: bool = False
35
+ machine_binding: bool = False
36
+ play_count_limit: int = 0
37
+ expiry_unix: int = 0
38
+ container_id: bytes = field(default_factory=lambda: b"\x00" * 16)
39
+ machine_fingerprint: bytes = field(default_factory=lambda: b"\x00" * 32)
40
+ watermark: str = ""
41
+ play_password_hash: str = ""
42
+ signing_pubkey: bytes = field(default_factory=lambda: b"\x00" * PUB_LEN)
43
+ signature: bytes = field(default_factory=lambda: b"\x00" * SIG_LEN)
44
+
45
+ @property
46
+ def flags(self) -> int:
47
+ f = 0
48
+ if self.open_password_required:
49
+ f |= FLAG_OPEN_PW
50
+ if self.play_password_required:
51
+ f |= FLAG_PLAY_PW
52
+ if self.machine_binding:
53
+ f |= FLAG_MACHINE_BIND
54
+ return f
55
+
56
+
57
+ def make_policy(
58
+ *,
59
+ signing_key: Ed25519PrivateKey | None = None,
60
+ open_password_required: bool = False,
61
+ play_password: bytes | None = None,
62
+ machine_fingerprint: bytes | None = None,
63
+ play_count_limit: int = 0,
64
+ expiry_unix: int = 0,
65
+ watermark: str = "",
66
+ container_id: bytes | None = None,
67
+ ) -> Policy:
68
+ p = Policy(
69
+ open_password_required=open_password_required,
70
+ play_password_required=play_password is not None,
71
+ machine_binding=machine_fingerprint is not None,
72
+ play_count_limit=play_count_limit,
73
+ expiry_unix=expiry_unix,
74
+ watermark=watermark,
75
+ container_id=container_id or _random_cid(),
76
+ )
77
+ if machine_fingerprint is not None:
78
+ if len(machine_fingerprint) != 32:
79
+ raise ValueError("machine_fingerprint must be 32 bytes")
80
+ p.machine_fingerprint = machine_fingerprint
81
+ if play_password is not None:
82
+ p.play_password_hash = hash_password(play_password)
83
+ if signing_key is not None:
84
+ sign_policy(p, signing_key)
85
+ return p
86
+
87
+
88
+ def _random_cid() -> bytes:
89
+ from ..primitives.rng import random_bytes
90
+ return random_bytes(16)
91
+
92
+
93
+ def pack_policy(p: Policy, *, include_signature: bool = True) -> bytes:
94
+ wm = p.watermark.encode("utf-8", "surrogatepass")
95
+ ph = p.play_password_hash.encode("utf-8", "surrogatepass")
96
+ body = struct.pack(
97
+ "<BBHIQ16s32s",
98
+ POLICY_VERSION, p.flags, 0, p.play_count_limit,
99
+ p.expiry_unix, p.container_id, p.machine_fingerprint,
100
+ )
101
+ body += struct.pack("<I", len(wm)) + wm
102
+ body += struct.pack("<I", len(ph)) + ph
103
+ body += p.signing_pubkey
104
+ if include_signature:
105
+ body += p.signature
106
+ return body
107
+
108
+
109
+ def unpack_policy(buf: bytes | memoryview) -> Policy:
110
+ if len(buf) < FIXED_LEN + 8:
111
+ raise PolicyError("policy section too short")
112
+ try:
113
+ ver, flags, _, limit, exp, cid, fp = struct.unpack_from("<BBHIQ16s32s", buf)
114
+ except struct.error as e:
115
+ raise PolicyError("policy header malformed") from e
116
+ if ver != POLICY_VERSION:
117
+ raise PolicyError(f"unsupported policy version {ver}")
118
+ off = FIXED_LEN
119
+ (wlen,) = struct.unpack_from("<I", buf, off); off += 4
120
+ if off + wlen + 4 > len(buf):
121
+ raise PolicyError("watermark overruns")
122
+ try:
123
+ wm = bytes(buf[off:off + wlen]).decode("utf-8", "surrogatepass")
124
+ except UnicodeDecodeError as e:
125
+ raise PolicyError("watermark not valid UTF-8") from e
126
+ off += wlen
127
+ try:
128
+ (plen,) = struct.unpack_from("<I", buf, off); off += 4
129
+ except struct.error as e:
130
+ raise PolicyError("password hash length malformed") from e
131
+ if off + plen + PUB_LEN + SIG_LEN > len(buf):
132
+ raise PolicyError("password hash overruns")
133
+ try:
134
+ ph = bytes(buf[off:off + plen]).decode("utf-8", "surrogatepass")
135
+ except UnicodeDecodeError as e:
136
+ raise PolicyError("password hash not valid UTF-8") from e
137
+ off += plen
138
+ pubkey = buf[off:off + PUB_LEN]; off += PUB_LEN
139
+ sig = buf[off:off + SIG_LEN]; off += SIG_LEN
140
+ if off != len(buf):
141
+ raise PolicyError("trailing policy bytes")
142
+ return Policy(
143
+ open_password_required=bool(flags & FLAG_OPEN_PW),
144
+ play_password_required=bool(flags & FLAG_PLAY_PW),
145
+ machine_binding=bool(flags & FLAG_MACHINE_BIND),
146
+ play_count_limit=limit,
147
+ expiry_unix=exp,
148
+ container_id=cid,
149
+ machine_fingerprint=fp,
150
+ watermark=wm,
151
+ play_password_hash=ph,
152
+ signing_pubkey=pubkey,
153
+ signature=sig,
154
+ )
155
+
156
+
157
+ def sign_policy(p: Policy, key: Ed25519PrivateKey) -> None:
158
+ p.signing_pubkey = key.public_key().public_bytes_raw()
159
+ body = pack_policy(p, include_signature=False)
160
+ p.signature = key.sign(body)
161
+
162
+
163
+ def verify_policy_signature(p: Policy, pinned_pubkey: bytes | None = None) -> str:
164
+ """Return 'pinned' or 'embedded' on success; raise PolicyError on failure."""
165
+ key_bytes = pinned_pubkey if pinned_pubkey is not None else p.signing_pubkey
166
+ if key_bytes == b"\x00" * PUB_LEN:
167
+ raise PolicyError("policy has no signature and no pinned key given")
168
+ if p.signature == b"\x00" * SIG_LEN:
169
+ raise PolicyError("policy unsigned")
170
+ body = pack_policy(p, include_signature=False)
171
+ try:
172
+ Ed25519PublicKey.from_public_bytes(key_bytes).verify(p.signature, body)
173
+ except InvalidSignature as e:
174
+ raise PolicyError("policy signature invalid (tampered?)") from e
175
+ return "pinned" if pinned_pubkey is not None else "embedded"
176
+
177
+
178
+ def enforce_policy(
179
+ p: Policy,
180
+ *,
181
+ now: float | None = None,
182
+ machine_fp: bytes | None = None,
183
+ pinned_pubkey: bytes | None = None,
184
+ ) -> None:
185
+ verify_policy_signature(p, pinned_pubkey)
186
+ ts = time.time() if now is None else now
187
+ if p.expiry_unix and ts > p.expiry_unix:
188
+ raise PolicyError("container expired")
189
+ if p.machine_binding:
190
+ fp = machine_fp if machine_fp is not None else get_fingerprint()
191
+ if len(fp) != 32:
192
+ raise PolicyError("machine fingerprint unavailable")
193
+ import hmac as _hmac
194
+ if not _hmac.compare_digest(fp, p.machine_fingerprint):
195
+ raise PolicyError("machine binding mismatch")
196
+
197
+
198
+ def check_play_password(p: Policy, password: bytes) -> None:
199
+ if not p.play_password_required:
200
+ return
201
+ if not p.play_password_hash:
202
+ raise PolicyError("play password required but no hash in policy")
203
+ if not verify_password_hash(password, p.play_password_hash):
204
+ raise PolicyError("wrong play password")
@@ -0,0 +1,233 @@
1
+ import hashlib
2
+ import hmac
3
+ import mmap
4
+ import struct
5
+ from dataclasses import dataclass
6
+ from typing import BinaryIO
7
+
8
+ from ..primitives.aead import chunk_nonce, open_sealed, unwrap_key
9
+ from ..primitives.kdf import derive_kek, k_mac
10
+ from . import layout as L
11
+ from .policy import Policy, PolicyError, check_play_password, enforce_policy, unpack_policy
12
+ from .state import PlayStateStore
13
+
14
+
15
+ @dataclass
16
+ class ParsedFile:
17
+ file_id: bytes
18
+ name: str
19
+ size: int
20
+ chunk_size: int
21
+ chunk_count: int
22
+ table: list[tuple[int, int, int]]
23
+
24
+
25
+ class ContainerError(Exception):
26
+ pass
27
+
28
+
29
+ class ContainerReader:
30
+ def __init__(
31
+ self,
32
+ blob: bytes | mmap.mmap,
33
+ password: bytes | None = None,
34
+ *,
35
+ now: float | None = None,
36
+ machine_fp: bytes | None = None,
37
+ policy_pubkey: bytes | None = None,
38
+ state_store: PlayStateStore | None = None,
39
+ enforce: bool = True,
40
+ ):
41
+ self.blob = blob
42
+ self._mv = memoryview(blob)
43
+ try:
44
+ self.header = L.parse_header(bytes(self._mv[:L.HEADER_SIZE]))
45
+ except ValueError as e:
46
+ raise ContainerError(str(e)) from e
47
+ self.sections: dict[int, bytes | memoryview] = {}
48
+ self._parse_tlvs()
49
+ self.policy: Policy | None = None
50
+ if L.SEC_POLICY in self.sections:
51
+ self.policy = unpack_policy(bytes(self.sections[L.SEC_POLICY]))
52
+ if enforce:
53
+ enforce_policy(
54
+ self.policy,
55
+ now=now,
56
+ machine_fp=machine_fp,
57
+ pinned_pubkey=policy_pubkey,
58
+ )
59
+ self._state = state_store
60
+ self._play_authorized = False
61
+ self.content_key = self._unwrap_key(password)
62
+ self._verify_mac()
63
+ self.files = self._parse_index()
64
+
65
+ @classmethod
66
+ def from_path(cls, path, **kw) -> "ContainerReader":
67
+ f = open(path, "rb")
68
+ try:
69
+ size = f.seek(0, 2)
70
+ if size < L.HEADER_SIZE + L.FOOTER_SIZE:
71
+ f.close()
72
+ raise ContainerError("file too small")
73
+ f.seek(0)
74
+ mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
75
+ except Exception:
76
+ f.close()
77
+ raise
78
+ try:
79
+ reader = cls(mm, **kw)
80
+ except BaseException:
81
+ mm.close()
82
+ f.close()
83
+ raise
84
+ reader._keepalive = (f, mm)
85
+ return reader
86
+
87
+ def close(self) -> None:
88
+ ka = getattr(self, "_keepalive", None)
89
+ if ka:
90
+ for v in self.sections.values():
91
+ if isinstance(v, memoryview):
92
+ v.release()
93
+ self.sections.clear()
94
+ mv = getattr(self, "_mv", None)
95
+ if isinstance(mv, memoryview):
96
+ mv.release()
97
+ f, mm = ka
98
+ mm.close()
99
+ f.close()
100
+ self._keepalive = None
101
+
102
+ def __enter__(self) -> "ContainerReader":
103
+ return self
104
+
105
+ def __exit__(self, *exc) -> None:
106
+ self.close()
107
+
108
+ @property
109
+ def watermark(self) -> str:
110
+ return self.policy.watermark if self.policy else ""
111
+
112
+ def authorize_play(self, play_password: bytes | None = None) -> int:
113
+ if self.policy is None:
114
+ self._play_authorized = True
115
+ return 0
116
+ if self.policy.play_password_required:
117
+ if play_password is None:
118
+ raise PolicyError("play password required")
119
+ check_play_password(self.policy, play_password)
120
+ limit = self.policy.play_count_limit
121
+ if limit:
122
+ store = self._state or PlayStateStore()
123
+ self._state = store
124
+ used = store.plays(self.policy.container_id)
125
+ if used >= limit:
126
+ raise PolicyError(f"play limit exhausted ({limit})")
127
+ store.record_play(self.policy.container_id)
128
+ self._play_authorized = True
129
+ return limit - used - 1
130
+ self._play_authorized = True
131
+ return 0
132
+
133
+ def _play_gate_active(self) -> bool:
134
+ return bool(
135
+ self.policy
136
+ and (self.policy.play_password_required or self.policy.play_count_limit)
137
+ )
138
+
139
+ def _parse_tlvs(self) -> None:
140
+ off = L.HEADER_SIZE
141
+ end = len(self.blob) - L.FOOTER_SIZE
142
+ for _ in range(self.header["section_count"]):
143
+ if off + L.TLV_STRUCT.size > end:
144
+ raise ContainerError("section header overruns file")
145
+ sec_type, length = L.TLV_STRUCT.unpack_from(self._mv, off)
146
+ off += L.TLV_STRUCT.size
147
+ if off + length > end:
148
+ raise ContainerError("section payload overruns file")
149
+ self.sections[sec_type] = self._mv[off:off + length]
150
+ off += length
151
+ if L.SEC_END not in self.sections:
152
+ raise ContainerError("missing SEC_END")
153
+
154
+ def _unwrap_key(self, password: bytes | None) -> bytes:
155
+ keys = bytes(self.sections.get(L.SEC_KEYS, b""))
156
+ if len(keys) < 33:
157
+ raise ContainerError("SEC_KEYS malformed")
158
+ purpose = keys[16]
159
+ kdf_id = keys[17]
160
+ if purpose != L.KEY_PURPOSE_CONTENT:
161
+ raise ContainerError("no content key record")
162
+ if kdf_id == 0:
163
+ return keys[34 + 12:]
164
+ if password is None:
165
+ raise ContainerError("password required")
166
+ salt = keys[18:34]
167
+ kek = derive_kek(password, salt)
168
+ key_id = keys[:16]
169
+ try:
170
+ return unwrap_key(kek, keys[34:], key_id)
171
+ except Exception as e:
172
+ raise ContainerError("wrong password") from e
173
+
174
+ def _verify_mac(self) -> None:
175
+ if len(self.blob) < L.FOOTER_SIZE:
176
+ raise ContainerError("footer missing")
177
+ magic, count, total, mac = L.FOOTER_STRUCT.unpack(
178
+ bytes(self.blob[-L.FOOTER_SIZE:])
179
+ )
180
+ if magic != L.MAGIC_FOOTER:
181
+ raise ContainerError("bad footer magic")
182
+ if total != len(self.blob):
183
+ raise ContainerError("size mismatch")
184
+ body = self._mv[: len(self.blob) - L.FOOTER_SIZE]
185
+ expect = hmac.new(k_mac(self.content_key), body, hashlib.sha256).digest()[:16]
186
+ if not hmac.compare_digest(mac, expect):
187
+ raise ContainerError("footer MAC mismatch (tampered?)")
188
+
189
+ def _parse_index(self) -> list[ParsedFile]:
190
+ buf = bytes(self.sections.get(L.SEC_INDEX, b""))
191
+ files, off = [], 0
192
+ try:
193
+ while off < len(buf):
194
+ file_id = buf[off:off + 16]; off += 16
195
+ (nlen,) = struct.unpack_from("<I", buf, off); off += 4
196
+ name = buf[off:off + nlen].decode("utf-8", "surrogatepass"); off += nlen
197
+ size, chunk_size, nchunks = struct.unpack_from("<QII", buf, off); off += 16
198
+ (tlen,) = struct.unpack_from("<I", buf, off); off += 4
199
+ table = [struct.unpack_from("<QQI", buf, off + i * 20) for i in range(nchunks)]
200
+ off += tlen
201
+ files.append(ParsedFile(file_id, name, size, chunk_size, nchunks, table))
202
+ except (struct.error, UnicodeDecodeError) as e:
203
+ raise ContainerError(f"SEC_INDEX malformed: {e}") from e
204
+ return files
205
+
206
+ def iter_chunks(self, index: int = 0):
207
+ if self._play_gate_active() and not self._play_authorized:
208
+ raise PolicyError("authorize_play() required before read_file()")
209
+ f = self.files[index]
210
+ data = self.sections.get(L.SEC_DATA, b"")
211
+ for idx, (_, data_off, stored_len) in enumerate(f.table):
212
+ stored = bytes(data[data_off:data_off + stored_len])
213
+ if len(stored) < 4:
214
+ raise ContainerError("chunk truncated")
215
+ (pt_len,) = struct.unpack_from("<I", stored)
216
+ nonce = chunk_nonce(f.file_id, idx)
217
+ aad = f.file_id + struct.pack("<Q", idx)
218
+ try:
219
+ pt = open_sealed(self.content_key, nonce, stored[4:], aad)
220
+ except Exception as e:
221
+ raise ContainerError(f"chunk {idx} failed authentication") from e
222
+ yield pt[:pt_len]
223
+
224
+ def read_file(self, index: int = 0) -> bytes:
225
+ f = self.files[index]
226
+ return b"".join(self.iter_chunks(index))[: f.size]
227
+
228
+ def extract_to(self, index: int, out: BinaryIO) -> int:
229
+ n = 0
230
+ for pt in self.iter_chunks(index):
231
+ out.write(pt)
232
+ n += len(pt)
233
+ return n