eif-sdk 0.1.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.
eif/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+ """EIF — Evidence Integrity Framework reference SDK.
2
+
3
+ EIF is an open file format that binds a photograph to cryptographic proof of
4
+ how it was captured. The proof is structural: it lives inside the container
5
+ alongside the pixels, covers a per-tile Merkle tree of the image, and cannot
6
+ be separated from the image without invalidating it.
7
+
8
+ Quick start::
9
+
10
+ from eif import KeyPair, CaptureAttestation, Device, EIFFile, imaging
11
+
12
+ keypair = KeyPair.generate()
13
+ attestation = CaptureAttestation(
14
+ device=Device(make="Acme", model="Field-1", app_id="com.example.claims"),
15
+ challenge="server-issued-nonce",
16
+ )
17
+ png = imaging.encode_png("damage.jpg")
18
+ eif = EIFFile.capture(png, attestation, keypair)
19
+ eif.save("damage.eif")
20
+
21
+ report = EIFFile.load("damage.eif").verify(
22
+ expected_public_key=keypair.public_key
23
+ )
24
+ print(report["valid"])
25
+
26
+ Specification: https://github.com/eif-format/specification
27
+ """
28
+
29
+ from .cab import CaptureAttestation, Device, Location
30
+ from .constants import (
31
+ DEFAULT_TILE_SIZE,
32
+ FORMAT_VERSION_MAJOR,
33
+ FORMAT_VERSION_MINOR,
34
+ PERMITTED_TILE_SIZES,
35
+ SIGNATURE_ALGORITHM,
36
+ VERIFICATION_LEVELS,
37
+ )
38
+ from .errors import (
39
+ EIFCanonicalisationError,
40
+ EIFCryptoError,
41
+ EIFError,
42
+ EIFFormatError,
43
+ EIFImageError,
44
+ EIFProvenanceError,
45
+ )
46
+ from .file import EIFFile, Seal, StructuralIntegrityLayer
47
+ from .keys import KeyPair, PublicKey
48
+ from .merkle import InclusionProof, MerkleTree
49
+ from .provenance import ProvenanceChain, ProvenanceEntry
50
+ from .verify import summarise, verify
51
+
52
+ __version__ = "0.1.0"
53
+ __spec_version__ = f"{FORMAT_VERSION_MAJOR}.{FORMAT_VERSION_MINOR}"
54
+
55
+ __all__ = [
56
+ "DEFAULT_TILE_SIZE",
57
+ "PERMITTED_TILE_SIZES",
58
+ "SIGNATURE_ALGORITHM",
59
+ "VERIFICATION_LEVELS",
60
+ "CaptureAttestation",
61
+ "Device",
62
+ "EIFCanonicalisationError",
63
+ "EIFCryptoError",
64
+ "EIFError",
65
+ "EIFFile",
66
+ "EIFFormatError",
67
+ "EIFImageError",
68
+ "EIFProvenanceError",
69
+ "InclusionProof",
70
+ "KeyPair",
71
+ "Location",
72
+ "MerkleTree",
73
+ "ProvenanceChain",
74
+ "ProvenanceEntry",
75
+ "PublicKey",
76
+ "Seal",
77
+ "StructuralIntegrityLayer",
78
+ "__spec_version__",
79
+ "__version__",
80
+ "summarise",
81
+ "verify",
82
+ ]
eif/cab.py ADDED
@@ -0,0 +1,178 @@
1
+ """The Capture Attestation Block.
2
+
3
+ The CAB records the circumstances of capture: when, by what device, under
4
+ what software, at what location if any, and — the part that matters most for
5
+ fraud — against what server-issued challenge.
6
+
7
+ The challenge is what separates EIF from a timestamp. A capture pipeline
8
+ requests a nonce from the relying party's server immediately before capture
9
+ and binds it into the CAB before signing. An image produced any other way
10
+ cannot carry a fresh, unused, server-recognised nonce. That is the control
11
+ that addresses the recapture attack, in which a fraudster displays a
12
+ generated image on a screen and photographs it with a genuine camera: the
13
+ photograph is real, the provenance metadata is honest, and the content is
14
+ fabricated. Provenance alone cannot catch this. A challenge bound to a
15
+ session the relying party opened can.
16
+
17
+ Location is optional and, when present, carries its own source and accuracy.
18
+ A verifier must not treat a location field as trustworthy merely because it
19
+ is signed; the signature attests that the capture software asserted it, not
20
+ that it is true.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ from dataclasses import dataclass, field
27
+ from datetime import datetime, timezone
28
+ from typing import Any
29
+
30
+ from .canonical import canonicalise
31
+ from .errors import EIFFormatError
32
+
33
+
34
+ def _utc_now() -> str:
35
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
36
+
37
+
38
+ @dataclass
39
+ class Device:
40
+ """Identity of the capturing hardware and software."""
41
+
42
+ make: str = ""
43
+ model: str = ""
44
+ os: str = ""
45
+ app_id: str = ""
46
+ app_version: str = ""
47
+
48
+ def to_dict(self) -> dict[str, Any]:
49
+ return {
50
+ "app_id": self.app_id,
51
+ "app_version": self.app_version,
52
+ "make": self.make,
53
+ "model": self.model,
54
+ "os": self.os,
55
+ }
56
+
57
+ @classmethod
58
+ def from_dict(cls, data: dict[str, Any]) -> Device:
59
+ return cls(
60
+ make=data.get("make", ""),
61
+ model=data.get("model", ""),
62
+ os=data.get("os", ""),
63
+ app_id=data.get("app_id", ""),
64
+ app_version=data.get("app_version", ""),
65
+ )
66
+
67
+
68
+ @dataclass
69
+ class Location:
70
+ """A geographic assertion made by the capture software.
71
+
72
+ Latitude and longitude are strings holding decimal degrees. They are not
73
+ floating-point numbers because signed structures must canonicalise
74
+ identically on every implementation, and float formatting does not.
75
+ """
76
+
77
+ latitude: str
78
+ longitude: str
79
+ accuracy_m: int
80
+ source: str = "gps" # gps | network | fused | manual
81
+
82
+ def to_dict(self) -> dict[str, Any]:
83
+ return {
84
+ "accuracy_m": self.accuracy_m,
85
+ "latitude": self.latitude,
86
+ "longitude": self.longitude,
87
+ "source": self.source,
88
+ }
89
+
90
+ @classmethod
91
+ def from_dict(cls, data: dict[str, Any]) -> Location:
92
+ return cls(
93
+ latitude=str(data["latitude"]),
94
+ longitude=str(data["longitude"]),
95
+ accuracy_m=int(data["accuracy_m"]),
96
+ source=data.get("source", "gps"),
97
+ )
98
+
99
+
100
+ @dataclass
101
+ class CaptureAttestation:
102
+ """The signed record of how an image came to exist."""
103
+
104
+ capture_time: str = field(default_factory=_utc_now)
105
+ device: Device = field(default_factory=Device)
106
+ location: Location | None = None
107
+ #: Server-issued challenge bound into this capture. Absent for offline
108
+ #: captures, which a relying party should treat as a lower assurance tier.
109
+ challenge: str | None = None
110
+ #: Identifier for the relying party's capture session, if any.
111
+ session_id: str | None = None
112
+ #: Free-form, non-normative annotations. Excluded from no signature —
113
+ #: everything in the CAB is signed — but carrying no defined meaning.
114
+ annotations: dict[str, Any] = field(default_factory=dict)
115
+
116
+ # Populated by the SDK at capture time; not set by callers.
117
+ image_width: int = 0
118
+ image_height: int = 0
119
+ image_format: str = ""
120
+ tile_size: int = 0
121
+ key_id: str = ""
122
+
123
+ def to_dict(self) -> dict[str, Any]:
124
+ data: dict[str, Any] = {
125
+ "capture_time": self.capture_time,
126
+ "device": self.device.to_dict(),
127
+ "image": {
128
+ "format": self.image_format,
129
+ "height": self.image_height,
130
+ "tile_size": self.tile_size,
131
+ "width": self.image_width,
132
+ },
133
+ "key_id": self.key_id,
134
+ "spec_version": "1.0",
135
+ }
136
+ if self.location is not None:
137
+ data["location"] = self.location.to_dict()
138
+ if self.challenge is not None:
139
+ data["challenge"] = self.challenge
140
+ if self.session_id is not None:
141
+ data["session_id"] = self.session_id
142
+ if self.annotations:
143
+ data["annotations"] = self.annotations
144
+ return data
145
+
146
+ @classmethod
147
+ def from_dict(cls, data: dict[str, Any]) -> CaptureAttestation:
148
+ try:
149
+ image = data["image"]
150
+ return cls(
151
+ capture_time=data["capture_time"],
152
+ device=Device.from_dict(data.get("device", {})),
153
+ location=(
154
+ Location.from_dict(data["location"]) if "location" in data else None
155
+ ),
156
+ challenge=data.get("challenge"),
157
+ session_id=data.get("session_id"),
158
+ annotations=data.get("annotations", {}),
159
+ image_width=int(image["width"]),
160
+ image_height=int(image["height"]),
161
+ image_format=str(image["format"]),
162
+ tile_size=int(image["tile_size"]),
163
+ key_id=str(data["key_id"]),
164
+ )
165
+ except (KeyError, TypeError, ValueError) as exc:
166
+ raise EIFFormatError(f"malformed Capture Attestation Block: {exc}") from exc
167
+
168
+ def encode(self) -> bytes:
169
+ return canonicalise(self.to_dict())
170
+
171
+ @classmethod
172
+ def decode(cls, payload: bytes) -> CaptureAttestation:
173
+ from .canonical import parse
174
+
175
+ return cls.from_dict(parse(payload))
176
+
177
+ def digest(self) -> bytes:
178
+ return hashlib.sha256(self.encode()).digest()
eif/canonical.py ADDED
@@ -0,0 +1,76 @@
1
+ """Canonical serialisation.
2
+
3
+ Signatures are computed over bytes, not over objects. Two implementations
4
+ that serialise the same Capture Attestation Block differently would produce
5
+ different signatures over identical data, so the specification fixes exactly
6
+ one byte representation.
7
+
8
+ EIF v1.0 uses JSON Canonicalization Scheme (RFC 8785) semantics, restricted
9
+ to the subset of JSON the format actually uses: objects, arrays, strings,
10
+ integers, booleans and null. Floating-point values are prohibited in
11
+ signed structures precisely because their shortest round-trip representation
12
+ is the hardest part of RFC 8785 to implement consistently; where a decimal
13
+ quantity is needed (latitude, for example) the specification stores it as a
14
+ string.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from typing import Any
21
+
22
+ from .errors import EIFCanonicalisationError
23
+
24
+
25
+ def _check(value: Any, path: str = "$") -> None:
26
+ """Reject values that cannot be canonicalised deterministically."""
27
+ if value is None or isinstance(value, (str, bool)):
28
+ return
29
+ if isinstance(value, float):
30
+ raise EIFCanonicalisationError(
31
+ f"floating-point value at {path}; use a string-encoded decimal instead"
32
+ )
33
+ if isinstance(value, int):
34
+ # JSON numbers beyond 2^53 lose precision in many parsers.
35
+ if abs(value) > 2**53 - 1:
36
+ raise EIFCanonicalisationError(
37
+ f"integer at {path} exceeds the safe range; encode it as a string"
38
+ )
39
+ return
40
+ if isinstance(value, dict):
41
+ for key, item in value.items():
42
+ if not isinstance(key, str):
43
+ raise EIFCanonicalisationError(f"non-string object key at {path}")
44
+ _check(item, f"{path}.{key}")
45
+ return
46
+ if isinstance(value, list):
47
+ for index, item in enumerate(value):
48
+ _check(item, f"{path}[{index}]")
49
+ return
50
+ raise EIFCanonicalisationError(
51
+ f"value of type {type(value).__name__} at {path} is not representable"
52
+ )
53
+
54
+
55
+ def canonicalise(value: Any) -> bytes:
56
+ """Serialise ``value`` to the single byte string EIF signs over.
57
+
58
+ Object keys are sorted by Unicode code point, no insignificant whitespace
59
+ is emitted, and the result is UTF-8.
60
+ """
61
+ _check(value)
62
+ return json.dumps(
63
+ value,
64
+ sort_keys=True,
65
+ separators=(",", ":"),
66
+ ensure_ascii=False,
67
+ allow_nan=False,
68
+ ).encode("utf-8")
69
+
70
+
71
+ def parse(data: bytes) -> Any:
72
+ """Parse canonical JSON bytes back into Python objects."""
73
+ try:
74
+ return json.loads(data.decode("utf-8"))
75
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
76
+ raise EIFCanonicalisationError(f"payload is not valid canonical JSON: {exc}") from exc
eif/cli.py ADDED
@@ -0,0 +1,255 @@
1
+ """Command line interface: ``eif``.
2
+
3
+ eif keygen --out signing-key.pem
4
+ eif capture damage.png --key signing-key.pem --out damage.eif
5
+ eif verify damage.eif [--key public.pem] [--json]
6
+ eif inspect damage.eif
7
+ eif append damage.eif --action received --actor "adjuster:4471"
8
+ eif extract damage.eif --out damage.png
9
+ eif tamper-test damage.eif --out tampered.eif
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from . import __version__, imaging
20
+ from .cab import CaptureAttestation, Device, Location
21
+ from .constants import DEFAULT_TILE_SIZE, PERMITTED_TILE_SIZES
22
+ from .errors import EIFError
23
+ from .file import EIFFile
24
+ from .keys import KeyPair, PublicKey
25
+ from .verify import summarise
26
+
27
+
28
+ def _cmd_keygen(args: argparse.Namespace) -> int:
29
+ keypair = KeyPair.generate()
30
+ keypair.save(args.out)
31
+ public_path = Path(args.out).with_suffix(".pub.pem")
32
+ keypair.public_key.save(public_path)
33
+ print(f"Private key : {args.out}")
34
+ print(f"Public key : {public_path}")
35
+ print(f"Key ID : {keypair.key_id}")
36
+ print()
37
+ print("Keep the private key out of version control. This is a software key,")
38
+ print("suitable for development. Production capture should sign in hardware.")
39
+ return 0
40
+
41
+
42
+ def _cmd_capture(args: argparse.Namespace) -> int:
43
+ keypair = KeyPair.load(args.key)
44
+ source = Path(args.image)
45
+ data = source.read_bytes() if source.suffix.lower() == ".png" else imaging.encode_png(source)
46
+
47
+ location = None
48
+ if args.location:
49
+ try:
50
+ lat, lon = args.location.split(",")
51
+ location = Location(
52
+ latitude=lat.strip(), longitude=lon.strip(), accuracy_m=args.location_accuracy
53
+ )
54
+ except ValueError:
55
+ print("--location must be 'latitude,longitude'", file=sys.stderr)
56
+ return 2
57
+
58
+ attestation = CaptureAttestation(
59
+ device=Device(
60
+ make=args.make, model=args.model, os=args.os,
61
+ app_id=args.app_id, app_version=args.app_version,
62
+ ),
63
+ location=location,
64
+ challenge=args.challenge,
65
+ session_id=args.session,
66
+ )
67
+ eif = EIFFile.capture(data, attestation, keypair, tile_size=args.tile_size)
68
+ out = args.out or str(source.with_suffix(".eif"))
69
+ eif.save(out)
70
+ print(f"Sealed : {out}")
71
+ print(f"Image : {eif.cab.image_width}x{eif.cab.image_height}, "
72
+ f"{eif.sil.columns}x{eif.sil.rows} tiles of {eif.sil.tile_size}px")
73
+ print(f"Root : {eif.sil.root.hex()}")
74
+ print(f"Key ID : {eif.cab.key_id}")
75
+ if args.challenge is None:
76
+ print()
77
+ print("No challenge was bound into this capture. Offline captures cannot be")
78
+ print("distinguished from a photograph of a screen; treat as lower assurance.")
79
+ return 0
80
+
81
+
82
+ def _cmd_verify(args: argparse.Namespace) -> int:
83
+ eif = EIFFile.load(args.file)
84
+ expected = PublicKey.load(args.key) if args.key else None
85
+ report = eif.verify(expected_public_key=expected)
86
+ if args.json:
87
+ print(json.dumps(report, indent=2, default=str))
88
+ else:
89
+ print(summarise(report))
90
+ if expected is None:
91
+ print()
92
+ print("No key was pinned with --key. This checks internal consistency only:")
93
+ print("it does not establish who signed the file.")
94
+ return 0 if report["valid"] else 1
95
+
96
+
97
+ def _cmd_inspect(args: argparse.Namespace) -> int:
98
+ eif = EIFFile.load(args.file)
99
+ print(f"EIF file : {args.file}")
100
+ print("Spec version : 1.0")
101
+ print(f"Capture time : {eif.cab.capture_time}")
102
+ device = eif.cab.device
103
+ print(f"Device : {device.make} {device.model} ({device.os})".rstrip())
104
+ print(f"Application : {device.app_id} {device.app_version}".rstrip())
105
+ if eif.cab.location:
106
+ loc = eif.cab.location
107
+ print(f"Location : {loc.latitude}, {loc.longitude} "
108
+ f"(+/-{loc.accuracy_m}m, {loc.source})")
109
+ print(f"Challenge : {eif.cab.challenge or '(none)'}")
110
+ print(f"Session : {eif.cab.session_id or '(none)'}")
111
+ print(f"Image : {eif.cab.image_width}x{eif.cab.image_height} "
112
+ f"{eif.cab.image_format}")
113
+ print(f"Tile grid : {eif.sil.columns}x{eif.sil.rows} @ {eif.sil.tile_size}px "
114
+ f"({len(eif.sil.leaves)} tiles)")
115
+ print(f"Merkle root : {eif.sil.root.hex()}")
116
+ print(f"Image SHA-256 : {eif.image_hash}")
117
+ print(f"Algorithm : {eif.seal.algorithm}")
118
+ print(f"Key ID : {eif.cab.key_id}")
119
+ print(f"Provenance : {len(eif.provenance.entries)} entries")
120
+ for entry in eif.provenance.entries:
121
+ signed = "signed" if entry.signature else "unsigned"
122
+ print(f" [{entry.seq}] {entry.timestamp} {entry.action:<12} "
123
+ f"{entry.actor} ({signed})")
124
+ return 0
125
+
126
+
127
+ def _cmd_append(args: argparse.Namespace) -> int:
128
+ eif = EIFFile.load(args.file)
129
+ keypair = KeyPair.load(args.key) if args.key else None
130
+ detail = json.loads(args.detail) if args.detail else None
131
+ entry = eif.provenance.append(
132
+ action=args.action, actor=args.actor, detail=detail, keypair=keypair
133
+ )
134
+ eif.save(args.out or args.file)
135
+ print(f"Appended entry {entry.seq}: {entry.action} by {entry.actor}")
136
+ print(f"Entry hash : {entry.entry_hash}")
137
+ if keypair is None:
138
+ print("Entry is unsigned; the chain records it but does not attribute it.")
139
+ return 0
140
+
141
+
142
+ def _cmd_extract(args: argparse.Namespace) -> int:
143
+ eif = EIFFile.load(args.file)
144
+ eif.export_image(args.out)
145
+ print(f"Wrote {args.out}")
146
+ return 0
147
+
148
+
149
+ def _cmd_tamper_test(args: argparse.Namespace) -> int:
150
+ """Alter a region of the embedded image and show that EIF localises it."""
151
+ import io
152
+
153
+ from PIL import Image
154
+
155
+ eif = EIFFile.load(args.file)
156
+ with Image.open(io.BytesIO(eif.image_data)) as img:
157
+ edited = img.convert("RGBA")
158
+ x0 = min(args.x, edited.width - 1)
159
+ y0 = min(args.y, edited.height - 1)
160
+ x1 = min(x0 + args.width, edited.width)
161
+ y1 = min(y0 + args.height, edited.height)
162
+ for y in range(y0, y1):
163
+ for x in range(x0, x1):
164
+ edited.putpixel((x, y), (255, 0, 0, 255))
165
+ buffer = io.BytesIO()
166
+ edited.save(buffer, format="PNG", optimize=False)
167
+ eif.image_data = buffer.getvalue()
168
+
169
+ out = args.out or str(Path(args.file).with_suffix(".tampered.eif"))
170
+ eif.save(out)
171
+ print(f"Wrote tampered file: {out}")
172
+ print(f"Altered region: [{x0},{y0}] to [{x1},{y1}]")
173
+ print()
174
+ print(summarise(EIFFile.load(out).verify()))
175
+ return 0
176
+
177
+
178
+ def build_parser() -> argparse.ArgumentParser:
179
+ parser = argparse.ArgumentParser(
180
+ prog="eif", description="Evidence Integrity Framework reference tool"
181
+ )
182
+ parser.add_argument("--version", action="version", version=f"eif {__version__}")
183
+ sub = parser.add_subparsers(dest="command", required=True)
184
+
185
+ p = sub.add_parser("keygen", help="generate a P-256 signing key")
186
+ p.add_argument("--out", default="eif-signing-key.pem")
187
+ p.set_defaults(func=_cmd_keygen)
188
+
189
+ p = sub.add_parser("capture", help="seal an image into an EIF file")
190
+ p.add_argument("image")
191
+ p.add_argument("--key", required=True)
192
+ p.add_argument("--out")
193
+ p.add_argument("--tile-size", type=int, default=DEFAULT_TILE_SIZE,
194
+ choices=PERMITTED_TILE_SIZES)
195
+ p.add_argument("--challenge", help="server-issued nonce to bind into the capture")
196
+ p.add_argument("--session", help="relying-party session identifier")
197
+ p.add_argument("--location", help="'latitude,longitude' in decimal degrees")
198
+ p.add_argument("--location-accuracy", type=int, default=0)
199
+ p.add_argument("--make", default="")
200
+ p.add_argument("--model", default="")
201
+ p.add_argument("--os", default="")
202
+ p.add_argument("--app-id", default="")
203
+ p.add_argument("--app-version", default="")
204
+ p.set_defaults(func=_cmd_capture)
205
+
206
+ p = sub.add_parser("verify", help="run the L1-L5 verification protocol")
207
+ p.add_argument("file")
208
+ p.add_argument("--key", help="pin the expected signing public key (PEM)")
209
+ p.add_argument("--json", action="store_true")
210
+ p.set_defaults(func=_cmd_verify)
211
+
212
+ p = sub.add_parser("inspect", help="print the contents of an EIF file")
213
+ p.add_argument("file")
214
+ p.set_defaults(func=_cmd_inspect)
215
+
216
+ p = sub.add_parser("append", help="append a provenance chain entry")
217
+ p.add_argument("file")
218
+ p.add_argument("--action", required=True)
219
+ p.add_argument("--actor", required=True)
220
+ p.add_argument("--detail", help="JSON object of additional detail")
221
+ p.add_argument("--key", help="sign the entry with this private key")
222
+ p.add_argument("--out")
223
+ p.set_defaults(func=_cmd_append)
224
+
225
+ p = sub.add_parser("extract", help="write the embedded image to a file")
226
+ p.add_argument("file")
227
+ p.add_argument("--out", required=True)
228
+ p.set_defaults(func=_cmd_extract)
229
+
230
+ p = sub.add_parser("tamper-test", help="alter a region and localise the change")
231
+ p.add_argument("file")
232
+ p.add_argument("--x", type=int, default=0)
233
+ p.add_argument("--y", type=int, default=0)
234
+ p.add_argument("--width", type=int, default=100)
235
+ p.add_argument("--height", type=int, default=100)
236
+ p.add_argument("--out")
237
+ p.set_defaults(func=_cmd_tamper_test)
238
+
239
+ return parser
240
+
241
+
242
+ def main(argv: list[str] | None = None) -> int:
243
+ args = build_parser().parse_args(argv)
244
+ try:
245
+ return int(args.func(args))
246
+ except EIFError as exc:
247
+ print(f"error: {exc}", file=sys.stderr)
248
+ return 2
249
+ except FileNotFoundError as exc:
250
+ print(f"error: {exc}", file=sys.stderr)
251
+ return 2
252
+
253
+
254
+ if __name__ == "__main__":
255
+ raise SystemExit(main())
eif/constants.py ADDED
@@ -0,0 +1,92 @@
1
+ """Constants defined by the EIF Format Specification v1.0.
2
+
3
+ Every value in this module is normative. Changing one produces files that
4
+ are not EIF files. See the specification repository:
5
+ https://github.com/eif-format/specification
6
+ """
7
+
8
+ # --- Container ---------------------------------------------------------------
9
+
10
+ #: File magic. Eight bytes, chosen on the same principle as the PNG signature:
11
+ #: byte 0 has the high bit set so that transfer over a 7-bit channel is
12
+ #: detected; b"\r\n" then b"\x1a" then b"\n" detect CRLF translation, DOS
13
+ #: end-of-file truncation, and LF translation respectively.
14
+ MAGIC = b"\x89EIF\r\n\x1a\n"
15
+
16
+ FORMAT_VERSION_MAJOR = 1
17
+ FORMAT_VERSION_MINOR = 0
18
+
19
+ #: Fixed-size file header: MAGIC (8) + major (1) + minor (1) + reserved (2)
20
+ #: + chunk_count (4).
21
+ HEADER_SIZE = 16
22
+
23
+ #: Chunk framing: type (4) + length (4) + payload (length) + crc32 (4).
24
+ CHUNK_TYPE_SIZE = 4
25
+ CHUNK_LENGTH_SIZE = 4
26
+ CHUNK_CRC_SIZE = 4
27
+
28
+ # Chunk type codes.
29
+ CHUNK_CAB = b"CABK" # Capture Attestation Block
30
+ CHUNK_SIL = b"SILT" # Structural Integrity Layer
31
+ CHUNK_SEAL = b"SEAL" # Cryptographic Seal
32
+ CHUNK_PVCH = b"PVCH" # Provenance Chain
33
+ CHUNK_IMGD = b"IMGD" # Image Data
34
+ CHUNK_IEND = b"IEND" # End of file
35
+
36
+ #: Chunks that must be present in a conformant v1.0 file, in this order.
37
+ REQUIRED_CHUNKS = (CHUNK_CAB, CHUNK_SIL, CHUNK_SEAL, CHUNK_PVCH, CHUNK_IMGD, CHUNK_IEND)
38
+
39
+ #: Maximum size of any single chunk payload (256 MiB). Bounds memory use in
40
+ #: verifiers processing untrusted input.
41
+ MAX_CHUNK_PAYLOAD = 256 * 1024 * 1024
42
+
43
+ # --- Structural Integrity Layer ----------------------------------------------
44
+
45
+ #: Default tile edge in pixels. The specification permits 16, 32, 64 or 128.
46
+ DEFAULT_TILE_SIZE = 64
47
+ PERMITTED_TILE_SIZES = (16, 32, 64, 128)
48
+
49
+ #: Domain separation tags for the Merkle tree, following the construction in
50
+ #: RFC 6962 Section 2.1. Distinct prefixes for leaves and internal nodes
51
+ #: prevent the second-preimage attack that affects naive Merkle trees.
52
+ MERKLE_LEAF_PREFIX = b"\x00"
53
+ MERKLE_NODE_PREFIX = b"\x01"
54
+
55
+ # --- Cryptography ------------------------------------------------------------
56
+
57
+ #: The only signature algorithm defined in v1.0: ECDSA over NIST P-256 with
58
+ #: SHA-256, as specified in FIPS 186-4.
59
+ SIGNATURE_ALGORITHM = "ES256"
60
+
61
+ HASH_ALGORITHM = "SHA-256"
62
+
63
+ #: Domain separation string prefixed to the seal signing input. Prevents a
64
+ #: signature produced for one EIF purpose being replayed as another.
65
+ SEAL_DOMAIN = b"EIF-SEAL-v1\x00"
66
+
67
+ #: Domain separation string for provenance chain entry hashing.
68
+ PROVENANCE_DOMAIN = b"EIF-PVCH-v1\x00"
69
+
70
+ # --- Image data --------------------------------------------------------------
71
+
72
+ #: v1.0 stores image data in a losslessly decodable form so that tile hashes
73
+ #: recompute bit-identically on any conformant decoder. Lossy payload profiles
74
+ #: are deferred to a later version; see specification Section 5.4.
75
+ PERMITTED_IMAGE_FORMATS = ("PNG",)
76
+
77
+ # --- Verification ------------------------------------------------------------
78
+
79
+ VERIFICATION_LEVELS = (
80
+ "L1_format",
81
+ "L2_cryptographic",
82
+ "L3_temporal",
83
+ "L4_provenance",
84
+ "L5_device",
85
+ )
86
+
87
+ #: Levels that a verifier can complete with no network access.
88
+ OFFLINE_LEVELS = ("L1_format", "L2_cryptographic", "L3_temporal", "L4_provenance")
89
+
90
+ #: Default tolerance, in seconds, for L3 temporal checks. A capture timestamp
91
+ #: further ahead of the verifier's clock than this is rejected.
92
+ DEFAULT_CLOCK_SKEW_TOLERANCE = 300