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 +82 -0
- eif/cab.py +178 -0
- eif/canonical.py +76 -0
- eif/cli.py +255 -0
- eif/constants.py +92 -0
- eif/container.py +120 -0
- eif/errors.py +30 -0
- eif/file.py +257 -0
- eif/imaging.py +137 -0
- eif/keys.py +181 -0
- eif/merkle.py +136 -0
- eif/provenance.py +206 -0
- eif/verify.py +362 -0
- eif_sdk-0.1.0.dist-info/METADATA +199 -0
- eif_sdk-0.1.0.dist-info/RECORD +20 -0
- eif_sdk-0.1.0.dist-info/WHEEL +5 -0
- eif_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- eif_sdk-0.1.0.dist-info/licenses/LICENSE +201 -0
- eif_sdk-0.1.0.dist-info/licenses/NOTICE +12 -0
- eif_sdk-0.1.0.dist-info/top_level.txt +1 -0
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
|