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 +3 -0
- ocf/binding/__init__.py +13 -0
- ocf/binding/machine.py +125 -0
- ocf/container/__init__.py +20 -0
- ocf/container/layout.py +60 -0
- ocf/container/policy.py +204 -0
- ocf/container/reader.py +233 -0
- ocf/container/state.py +42 -0
- ocf/container/writer.py +163 -0
- ocf/primitives/__init__.py +11 -0
- ocf/primitives/aead.py +29 -0
- ocf/primitives/kdf.py +41 -0
- ocf/primitives/rng.py +5 -0
- ocf/primitives/signing.py +25 -0
- ocf/tools/__init__.py +0 -0
- ocf/tools/ocfctl.py +168 -0
- ocf_containers-1.0.0.dist-info/LICENSE +30 -0
- ocf_containers-1.0.0.dist-info/METADATA +196 -0
- ocf_containers-1.0.0.dist-info/RECORD +22 -0
- ocf_containers-1.0.0.dist-info/WHEEL +5 -0
- ocf_containers-1.0.0.dist-info/entry_points.txt +2 -0
- ocf_containers-1.0.0.dist-info/top_level.txt +1 -0
ocf/__init__.py
ADDED
ocf/binding/__init__.py
ADDED
|
@@ -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
|
+
]
|
ocf/container/layout.py
ADDED
|
@@ -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
|
ocf/container/policy.py
ADDED
|
@@ -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")
|
ocf/container/reader.py
ADDED
|
@@ -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
|