oept 0.1.1__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.
- oept/__init__.py +32 -0
- oept/bundle.py +175 -0
- oept/canonical.py +85 -0
- oept/cli.py +228 -0
- oept/conformance.py +74 -0
- oept/data/__init__.py +1 -0
- oept/data/conformance/01-valid-unsigned/bundle.zip +0 -0
- oept/data/conformance/01-valid-unsigned/expected-result.json +3 -0
- oept/data/conformance/02-valid-signed/bundle.zip +0 -0
- oept/data/conformance/02-valid-signed/expected-result.json +3 -0
- oept/data/conformance/02-valid-signed/trust-keys.json +6 -0
- oept/data/conformance/03-tampered-artifact/bundle.zip +0 -0
- oept/data/conformance/03-tampered-artifact/expected-result.json +3 -0
- oept/data/conformance/04-missing-member/bundle.zip +0 -0
- oept/data/conformance/04-missing-member/expected-result.json +3 -0
- oept/data/conformance/05-unsafe-path/bundle.zip +0 -0
- oept/data/conformance/05-unsafe-path/expected-result.json +3 -0
- oept/data/conformance/06-signature-invalid/bundle.zip +0 -0
- oept/data/conformance/06-signature-invalid/expected-result.json +3 -0
- oept/data/conformance/06-signature-invalid/trust-keys.json +6 -0
- oept/data/conformance/07-unknown-key/bundle.zip +0 -0
- oept/data/conformance/07-unknown-key/expected-result.json +3 -0
- oept/data/conformance/08-unexpected-member/bundle.zip +0 -0
- oept/data/conformance/08-unexpected-member/expected-result.json +3 -0
- oept/data/conformance/09-unsupported-version/bundle.zip +0 -0
- oept/data/conformance/09-unsupported-version/expected-result.json +3 -0
- oept/data/conformance/10-malformed-manifest/bundle.zip +0 -0
- oept/data/conformance/10-malformed-manifest/expected-result.json +3 -0
- oept/data/schemas/__init__.py +1 -0
- oept/data/schemas/bundle-manifest.schema.json +27 -0
- oept/data/schemas/evidence-object.schema.json +15 -0
- oept/data/schemas/evidence-version.schema.json +21 -0
- oept/data/schemas/fact.schema.json +30 -0
- oept/data/schemas/provenance-pointer.schema.json +18 -0
- oept/data/schemas/rule-trace.schema.json +20 -0
- oept/data/schemas/verification-result.schema.json +26 -0
- oept/errors.py +29 -0
- oept/hashing.py +37 -0
- oept/models.py +268 -0
- oept/py.typed +0 -0
- oept/schemas.py +93 -0
- oept/signing.py +131 -0
- oept/verify.py +555 -0
- oept-0.1.1.dist-info/METADATA +147 -0
- oept-0.1.1.dist-info/RECORD +49 -0
- oept-0.1.1.dist-info/WHEEL +5 -0
- oept-0.1.1.dist-info/entry_points.txt +2 -0
- oept-0.1.1.dist-info/licenses/LICENSE +200 -0
- oept-0.1.1.dist-info/top_level.txt +1 -0
oept/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""OEPT public API.
|
|
2
|
+
|
|
3
|
+
Intentionally tiny. Everything else is internal and may change.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .bundle import BundleBuilder, build_bundle
|
|
9
|
+
from .errors import (
|
|
10
|
+
ConfigurationError,
|
|
11
|
+
IntegrityError,
|
|
12
|
+
OeptError,
|
|
13
|
+
UnsupportedSpecError,
|
|
14
|
+
ValidationError,
|
|
15
|
+
)
|
|
16
|
+
from .models import VerificationResult
|
|
17
|
+
from .verify import verify_bundle, verify_bundle_bytes
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"BundleBuilder",
|
|
21
|
+
"ConfigurationError",
|
|
22
|
+
"IntegrityError",
|
|
23
|
+
"OeptError",
|
|
24
|
+
"UnsupportedSpecError",
|
|
25
|
+
"ValidationError",
|
|
26
|
+
"VerificationResult",
|
|
27
|
+
"build_bundle",
|
|
28
|
+
"verify_bundle",
|
|
29
|
+
"verify_bundle_bytes",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
__version__ = "0.1.1"
|
oept/bundle.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Deterministic evidence-bundle builder (oept-bundle/0.1).
|
|
2
|
+
|
|
3
|
+
A bundle is a deterministic ZIP with a canonical-JSON manifest. The manifest
|
|
4
|
+
lists and hashes every member except itself. Determinism inputs: sorted
|
|
5
|
+
member paths, fixed ZIP timestamps, fixed permission bits, DEFLATE
|
|
6
|
+
compression, canonical JSON payloads.
|
|
7
|
+
|
|
8
|
+
Reproducibility boundary (stated honestly): identical logical inputs
|
|
9
|
+
produce identical manifest bytes on any platform. Complete-ZIP-byte
|
|
10
|
+
identity additionally depends on the Python/zlib ZIP implementation and
|
|
11
|
+
is asserted only for same-interpreter round-trips in the test suite.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import io
|
|
17
|
+
import zipfile
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from .canonical import canonicalize_json
|
|
24
|
+
from .errors import ValidationError
|
|
25
|
+
from .hashing import sha256_bytes
|
|
26
|
+
from .models import utc_now_iso
|
|
27
|
+
|
|
28
|
+
BUNDLE_SPEC_VERSION = "oept-bundle/0.1"
|
|
29
|
+
|
|
30
|
+
#: Fixed DOS timestamp used for every ZIP member (reproducibility).
|
|
31
|
+
FIXED_ZIP_DATE_TIME = (2024, 1, 1, 0, 0, 0)
|
|
32
|
+
|
|
33
|
+
#: Fixed unix permission bits for ZIP members (0o644 << 16).
|
|
34
|
+
FIXED_EXTERNAL_ATTR = 0o644 << 16
|
|
35
|
+
|
|
36
|
+
REQUIRED_MEMBERS = ("manifest.json", "facts.json", "provenance.json")
|
|
37
|
+
|
|
38
|
+
EVIDENCE_PREFIX = "evidence/"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class BundleFile:
|
|
43
|
+
"""One logical bundle member: archive path + exact bytes."""
|
|
44
|
+
|
|
45
|
+
path: str
|
|
46
|
+
data: bytes
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def sha256(self) -> str:
|
|
50
|
+
return sha256_bytes(self.data)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _check_member_path(path: str) -> None:
|
|
54
|
+
if not path or path.startswith("/") or "\\" in path or "\x00" in path:
|
|
55
|
+
raise ValidationError(f"unsafe bundle member path: {path!r}")
|
|
56
|
+
parts = path.split("/")
|
|
57
|
+
if any(part in ("", ".", "..") for part in parts):
|
|
58
|
+
raise ValidationError(f"unsafe bundle member path: {path!r}")
|
|
59
|
+
if len(path) > 512:
|
|
60
|
+
raise ValidationError(f"bundle member path too long: {path!r}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class BundleBuilder:
|
|
65
|
+
"""Accumulates members, then emits manifest + deterministic ZIP."""
|
|
66
|
+
|
|
67
|
+
bundle_id: str
|
|
68
|
+
created_at: str = ""
|
|
69
|
+
_files: dict[str, bytes] = field(default_factory=dict)
|
|
70
|
+
key_id: str | None = None
|
|
71
|
+
|
|
72
|
+
def __post_init__(self) -> None:
|
|
73
|
+
if not self.bundle_id or not self.bundle_id.strip():
|
|
74
|
+
raise ValidationError("bundle_id must be non-empty")
|
|
75
|
+
if not self.created_at:
|
|
76
|
+
self.created_at = utc_now_iso()
|
|
77
|
+
|
|
78
|
+
def add_file(self, path: str, data: bytes) -> BundleBuilder:
|
|
79
|
+
"""Add one member. Duplicate paths are rejected (never overwritten)."""
|
|
80
|
+
_check_member_path(path)
|
|
81
|
+
if path in self._files:
|
|
82
|
+
raise ValidationError(f"duplicate bundle member path: {path!r}")
|
|
83
|
+
if path == "manifest.json":
|
|
84
|
+
raise ValidationError("manifest.json is generated by the builder")
|
|
85
|
+
if path == "signatures/manifest.sig":
|
|
86
|
+
raise ValidationError(
|
|
87
|
+
"signatures/manifest.sig is reserved for the signature; "
|
|
88
|
+
"pass signature= to build_zip_bytes instead"
|
|
89
|
+
)
|
|
90
|
+
self._files[path] = bytes(data)
|
|
91
|
+
return self
|
|
92
|
+
|
|
93
|
+
def add_evidence(self, name: str, data: bytes) -> BundleBuilder:
|
|
94
|
+
"""Add raw evidence bytes under evidence/."""
|
|
95
|
+
return self.add_file(f"{EVIDENCE_PREFIX}{name}", data)
|
|
96
|
+
|
|
97
|
+
def manifest_dict(self) -> dict[str, Any]:
|
|
98
|
+
"""Canonical manifest object (without signature members)."""
|
|
99
|
+
for required in ("facts.json", "provenance.json"):
|
|
100
|
+
if required not in self._files:
|
|
101
|
+
raise ValidationError(f"bundle is missing required member: {required}")
|
|
102
|
+
entries = [
|
|
103
|
+
{"path": path, "sha256": sha256_bytes(data), "size_bytes": len(data)}
|
|
104
|
+
for path, data in sorted(self._files.items())
|
|
105
|
+
]
|
|
106
|
+
manifest: dict[str, Any] = {
|
|
107
|
+
"bundle_spec_version": BUNDLE_SPEC_VERSION,
|
|
108
|
+
"bundle_id": self.bundle_id,
|
|
109
|
+
"created_at": self.created_at,
|
|
110
|
+
"files": entries,
|
|
111
|
+
}
|
|
112
|
+
if self.key_id:
|
|
113
|
+
manifest["key_id"] = self.key_id
|
|
114
|
+
return manifest
|
|
115
|
+
|
|
116
|
+
def manifest_bytes(self) -> bytes:
|
|
117
|
+
"""Canonical manifest bytes (the exact bytes that get signed)."""
|
|
118
|
+
return canonicalize_json(self.manifest_dict())
|
|
119
|
+
|
|
120
|
+
def _zip_members(self, signature: bytes | None = None) -> dict[str, bytes]:
|
|
121
|
+
members = dict(self._files)
|
|
122
|
+
if signature is not None:
|
|
123
|
+
if "signatures/manifest.sig" in members:
|
|
124
|
+
raise ValidationError("signatures/manifest.sig is reserved for the signature")
|
|
125
|
+
members["signatures/manifest.sig"] = signature
|
|
126
|
+
members["manifest.json"] = self.manifest_bytes()
|
|
127
|
+
return members
|
|
128
|
+
|
|
129
|
+
def build_zip_bytes(self, signature: bytes | None = None) -> bytes:
|
|
130
|
+
"""Emit the deterministic ZIP.
|
|
131
|
+
|
|
132
|
+
Envelope rule (see spec/bundle-spec.md): signatures/manifest.sig is
|
|
133
|
+
written into the archive but is deliberately NOT listed in
|
|
134
|
+
manifest.json's file table, so signature verification never needs
|
|
135
|
+
the signature to check itself. signatures/key-info.json, when added
|
|
136
|
+
via add_file, IS listed and therefore bound to the signed payload
|
|
137
|
+
(its key_id stays identification-only; trust is caller-supplied).
|
|
138
|
+
"""
|
|
139
|
+
members = self._zip_members(signature)
|
|
140
|
+
buffer = io.BytesIO()
|
|
141
|
+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
|
142
|
+
for path in sorted(members):
|
|
143
|
+
info = zipfile.ZipInfo(path, date_time=FIXED_ZIP_DATE_TIME)
|
|
144
|
+
info.compress_type = zipfile.ZIP_DEFLATED
|
|
145
|
+
info.external_attr = FIXED_EXTERNAL_ATTR
|
|
146
|
+
info.create_system = 3
|
|
147
|
+
archive.writestr(info, members[path])
|
|
148
|
+
return buffer.getvalue()
|
|
149
|
+
|
|
150
|
+
def write_zip(self, output: str | Path, signature: bytes | None = None) -> Path:
|
|
151
|
+
"""Write the deterministic ZIP to disk."""
|
|
152
|
+
target = Path(output)
|
|
153
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
154
|
+
target.write_bytes(self.build_zip_bytes(signature))
|
|
155
|
+
return target
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def build_bundle(
|
|
159
|
+
files: Mapping[str, bytes],
|
|
160
|
+
bundle_id: str,
|
|
161
|
+
created_at: str = "",
|
|
162
|
+
signature: bytes | None = None,
|
|
163
|
+
key_id: str | None = None,
|
|
164
|
+
) -> bytes:
|
|
165
|
+
"""One-shot helper: members + id -> deterministic ZIP bytes."""
|
|
166
|
+
builder = BundleBuilder(bundle_id=bundle_id, created_at=created_at, key_id=key_id)
|
|
167
|
+
for path in sorted(files):
|
|
168
|
+
builder.add_file(path, files[path])
|
|
169
|
+
manifest = builder.manifest_dict()
|
|
170
|
+
if signature is not None and not manifest.get("key_id") and key_id is None:
|
|
171
|
+
raise ValidationError(
|
|
172
|
+
"signed bundles must carry a key_id label "
|
|
173
|
+
"(identification only; trust comes from caller-supplied keys)"
|
|
174
|
+
)
|
|
175
|
+
return builder.build_zip_bytes(signature)
|
oept/canonical.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Deterministic canonical JSON (RFC 8785 / JCS).
|
|
2
|
+
|
|
3
|
+
OEPT does not implement its own canonicalizer. Canonical bytes are produced
|
|
4
|
+
by the maintained ``jcs`` package (RFC 8785 JSON Canonicalization Scheme).
|
|
5
|
+
If ``jcs`` cannot serialize a value, OEPT fails explicitly instead of
|
|
6
|
+
falling back to an incompatible rendering.
|
|
7
|
+
|
|
8
|
+
Supported value domain (the only domain OEPT schemas admit):
|
|
9
|
+
None, bool, int, str, list, tuple (as list), dict with str keys.
|
|
10
|
+
|
|
11
|
+
Anything else — float (including NaN/Infinity), bytes, Decimal, datetime,
|
|
12
|
+
set, custom objects — raises CanonicalizationError. Callers serialize
|
|
13
|
+
Decimals as strings and datetimes as UTC ISO-8601 strings BEFORE calling
|
|
14
|
+
into this module; that rule is enforced, not assumed.
|
|
15
|
+
|
|
16
|
+
Known JCS limitation (failed explicitly, never silently): the ``jcs``
|
|
17
|
+
library routes integers through binary64 floating point. Beyond 2**53,
|
|
18
|
+
values round (9007199254740993 renders as ...992; 2**63-1 renders as
|
|
19
|
+
...6000) and magnitudes >= 10**21 render in scientific notation, which
|
|
20
|
+
RFC 8785 forbids for integers. OEPT therefore rejects integers outside
|
|
21
|
+
±(2**53 - 1) outright — callers needing larger magnitudes must use
|
|
22
|
+
decimal strings, exactly like Decimal values.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import jcs
|
|
30
|
+
|
|
31
|
+
from .errors import OeptError
|
|
32
|
+
|
|
33
|
+
#: Largest integer magnitude serializable exactly. Every integer with
|
|
34
|
+
#: |n| <= 2**53 - 1 is exactly representable in binary64, and the JCS
|
|
35
|
+
#: backend renders this range in plain decimal notation. Anything larger
|
|
36
|
+
#: is rejected (silent rounding or scientific notation). Enforced below,
|
|
37
|
+
#: not assumed.
|
|
38
|
+
_MAX_EXACT_JCS_INT = 2**53 - 1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CanonicalizationError(OeptError):
|
|
42
|
+
"""A value outside the canonical domain was supplied."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _check_domain(value: Any, path: str = "$") -> None:
|
|
46
|
+
if value is None or isinstance(value, (bool, str)):
|
|
47
|
+
return
|
|
48
|
+
if isinstance(value, int):
|
|
49
|
+
if abs(value) > _MAX_EXACT_JCS_INT:
|
|
50
|
+
raise CanonicalizationError(
|
|
51
|
+
f"{path}: integer magnitude exceeds the exactly "
|
|
52
|
+
"serializable JCS range (use a decimal string instead)"
|
|
53
|
+
)
|
|
54
|
+
return
|
|
55
|
+
if isinstance(value, float):
|
|
56
|
+
raise CanonicalizationError(
|
|
57
|
+
f"{path}: float is not in the canonical domain "
|
|
58
|
+
"(serialize Decimals as strings, reject NaN/Infinity)"
|
|
59
|
+
)
|
|
60
|
+
if isinstance(value, (list, tuple)):
|
|
61
|
+
for i, item in enumerate(value):
|
|
62
|
+
_check_domain(item, f"{path}[{i}]")
|
|
63
|
+
return
|
|
64
|
+
if isinstance(value, dict):
|
|
65
|
+
for key in value:
|
|
66
|
+
if not isinstance(key, str):
|
|
67
|
+
raise CanonicalizationError(f"{path}: non-string dict key {key!r} is not canonical")
|
|
68
|
+
for key, item in value.items():
|
|
69
|
+
_check_domain(item, f"{path}.{key}")
|
|
70
|
+
return
|
|
71
|
+
raise CanonicalizationError(
|
|
72
|
+
f"{path}: type {type(value).__name__} is not in the canonical domain"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def canonicalize_json(value: Any) -> bytes:
|
|
77
|
+
"""Return RFC 8785 canonical bytes (UTF-8) for a domain value."""
|
|
78
|
+
_check_domain(value)
|
|
79
|
+
try:
|
|
80
|
+
result = jcs.canonicalize(value)
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
raise CanonicalizationError(f"JCS serialization failed: {exc}") from exc
|
|
83
|
+
if isinstance(result, str):
|
|
84
|
+
return result.encode("utf-8")
|
|
85
|
+
return bytes(result)
|
oept/cli.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""OEPT command line interface.
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
oept bundle DIRECTORY --output BUNDLE build a bundle from a directory
|
|
5
|
+
oept verify BUNDLE [--json] [--trust-keys FILE]
|
|
6
|
+
oept conformance [--dir DIR] [--json] run the conformance corpus
|
|
7
|
+
oept schema (list | show NAME) print JSON Schemas
|
|
8
|
+
|
|
9
|
+
Exit codes: 0 verified/valid, 1 integrity failure, 2 usage/config error.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .bundle import BundleBuilder
|
|
21
|
+
from .errors import ConfigurationError, OeptError, ValidationError
|
|
22
|
+
from .models import VerificationResult
|
|
23
|
+
from .signing import load_trust_keys, sign_manifest
|
|
24
|
+
from .verify import verify_bundle
|
|
25
|
+
|
|
26
|
+
FOOTER = "Technical verification only — not a legal, compliance, or factual conclusion."
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _render_human(bundle_label: str, result: VerificationResult) -> str:
|
|
30
|
+
if result.status == "VALID_SIGNED":
|
|
31
|
+
signature = (
|
|
32
|
+
f"SIGNED (trusted key: {result.trusted_key_id})" if result.trusted_key_id else "SIGNED"
|
|
33
|
+
)
|
|
34
|
+
elif result.status == "VALID_UNSIGNED":
|
|
35
|
+
signature = "NOT PRESENT"
|
|
36
|
+
elif result.status == "SIGNATURE_KEY_UNKNOWN":
|
|
37
|
+
signature = "PRESENT (no trusted key supplied or matched)"
|
|
38
|
+
elif result.status in ("SIGNATURE_INVALID",):
|
|
39
|
+
signature = "INVALID"
|
|
40
|
+
else:
|
|
41
|
+
signature = "NOT EVALUATED"
|
|
42
|
+
lines = [
|
|
43
|
+
f"Bundle: {bundle_label}",
|
|
44
|
+
f"Specification: {result.bundle_spec_version or '-'}",
|
|
45
|
+
f"Structure: {'VALID' if result.status not in ('MALFORMED_BUNDLE', 'UNSAFE_ARCHIVE', 'UNSUPPORTED_SPEC_VERSION') else 'INVALID'}",
|
|
46
|
+
f"Member digests: {'VALID' if not result.mismatches and not result.missing else 'INVALID'}",
|
|
47
|
+
f"Signature: {signature}",
|
|
48
|
+
f"Result: {result.status}",
|
|
49
|
+
]
|
|
50
|
+
for note in result.notes:
|
|
51
|
+
lines.append(f"Note: {note}")
|
|
52
|
+
if result.missing:
|
|
53
|
+
lines.append(f"Missing files: {len(result.missing)}")
|
|
54
|
+
if result.unexpected:
|
|
55
|
+
lines.append(f"Unexpected files: {len(result.unexpected)}")
|
|
56
|
+
if result.mismatches:
|
|
57
|
+
lines.append(f"Hash mismatches: {len(result.mismatches)}")
|
|
58
|
+
lines.append("")
|
|
59
|
+
lines.append(FOOTER)
|
|
60
|
+
return "\n".join(lines)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_verify(args: argparse.Namespace) -> int:
|
|
64
|
+
try:
|
|
65
|
+
trusted = load_trust_keys(args.trust_keys) if args.trust_keys else []
|
|
66
|
+
except ConfigurationError as exc:
|
|
67
|
+
print(f"oept: error: {exc}", file=sys.stderr)
|
|
68
|
+
return 2
|
|
69
|
+
target = Path(args.bundle)
|
|
70
|
+
if not target.exists():
|
|
71
|
+
print(f"oept: error: bundle not found: {target}", file=sys.stderr)
|
|
72
|
+
return 2
|
|
73
|
+
try:
|
|
74
|
+
result = verify_bundle(target, trusted)
|
|
75
|
+
except OeptError as exc:
|
|
76
|
+
print(f"oept: error: {exc}", file=sys.stderr)
|
|
77
|
+
return 2
|
|
78
|
+
if args.json:
|
|
79
|
+
print(json.dumps(result.to_dict(), indent=2, sort_keys=True))
|
|
80
|
+
else:
|
|
81
|
+
print(_render_human(str(target), result))
|
|
82
|
+
return 0 if result.ok else 1
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _iter_input_files(root: Path) -> dict[str, bytes]:
|
|
86
|
+
members: dict[str, bytes] = {}
|
|
87
|
+
for path in sorted(root.rglob("*")):
|
|
88
|
+
if path.is_dir():
|
|
89
|
+
continue
|
|
90
|
+
if path.is_symlink():
|
|
91
|
+
raise ValidationError(f"input contains symlink (refusing to bundle): {path}")
|
|
92
|
+
try:
|
|
93
|
+
name = path.relative_to(root).as_posix()
|
|
94
|
+
except ValueError as exc:
|
|
95
|
+
raise ValidationError(f"unresolvable input path: {path}") from exc
|
|
96
|
+
if name == "manifest.json" or name.startswith("signatures/"):
|
|
97
|
+
raise ValidationError(f"reserved bundle member in input directory: {name!r}")
|
|
98
|
+
members[name] = path.read_bytes()
|
|
99
|
+
if "facts.json" not in members or "provenance.json" not in members:
|
|
100
|
+
raise ValidationError("input directory must contain facts.json and provenance.json")
|
|
101
|
+
return members
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_bundle(args: argparse.Namespace) -> int:
|
|
105
|
+
if args.sign and not args.key_id:
|
|
106
|
+
print("oept: error: --sign requires --key-id", file=sys.stderr)
|
|
107
|
+
return 2
|
|
108
|
+
source = Path(args.directory)
|
|
109
|
+
if not source.is_dir():
|
|
110
|
+
print(f"oept: error: not a directory: {source}", file=sys.stderr)
|
|
111
|
+
return 2
|
|
112
|
+
try:
|
|
113
|
+
members = _iter_input_files(source)
|
|
114
|
+
builder = BundleBuilder(
|
|
115
|
+
bundle_id=args.bundle_id or source.name,
|
|
116
|
+
created_at=args.created_at or "",
|
|
117
|
+
key_id=args.key_id,
|
|
118
|
+
)
|
|
119
|
+
for path in sorted(members):
|
|
120
|
+
builder.add_file(path, members[path])
|
|
121
|
+
signature = None
|
|
122
|
+
if args.sign:
|
|
123
|
+
try:
|
|
124
|
+
private_key = Path(args.sign).read_bytes()
|
|
125
|
+
except OSError as exc:
|
|
126
|
+
print(f"oept: error: cannot read signing key: {exc}", file=sys.stderr)
|
|
127
|
+
return 2
|
|
128
|
+
try:
|
|
129
|
+
signature = sign_manifest(builder.manifest_bytes(), private_key)
|
|
130
|
+
except ConfigurationError as exc:
|
|
131
|
+
print(f"oept: error: {exc}", file=sys.stderr)
|
|
132
|
+
return 2
|
|
133
|
+
output = builder.write_zip(args.output, signature)
|
|
134
|
+
except (ValidationError, OeptError) as exc:
|
|
135
|
+
print(f"oept: error: {exc}", file=sys.stderr)
|
|
136
|
+
return 2
|
|
137
|
+
print(f"wrote {output} ({len(builder.manifest_dict()['files'])} members)")
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cmd_conformance(args: argparse.Namespace) -> int:
|
|
142
|
+
from .conformance import run_conformance
|
|
143
|
+
|
|
144
|
+
root = Path(args.dir) if args.dir else None
|
|
145
|
+
try:
|
|
146
|
+
report = run_conformance(root)
|
|
147
|
+
except OeptError as exc:
|
|
148
|
+
print(f"oept: error: {exc}", file=sys.stderr)
|
|
149
|
+
return 2
|
|
150
|
+
if args.json:
|
|
151
|
+
print(json.dumps(report, indent=2, sort_keys=True))
|
|
152
|
+
else:
|
|
153
|
+
for name, status, expected, ok in report["cases"]:
|
|
154
|
+
mark = "ok" if ok else "MISMATCH"
|
|
155
|
+
print(f"{mark:8} {name:28} got={status} want={expected}")
|
|
156
|
+
print(f"{report['passed']}/{report['total']} conformance cases pass")
|
|
157
|
+
return 0 if report["failed"] == 0 else 1
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cmd_schema(args: argparse.Namespace) -> int:
|
|
161
|
+
from .schemas import list_schemas, load_schema
|
|
162
|
+
|
|
163
|
+
if args.action == "list":
|
|
164
|
+
for name in list_schemas():
|
|
165
|
+
print(name)
|
|
166
|
+
return 0
|
|
167
|
+
try:
|
|
168
|
+
schema = load_schema(args.action)
|
|
169
|
+
except OeptError as exc:
|
|
170
|
+
print(f"oept: error: {exc}", file=sys.stderr)
|
|
171
|
+
return 2
|
|
172
|
+
print(json.dumps(schema, indent=2, sort_keys=True))
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
177
|
+
parser = argparse.ArgumentParser(prog="oept", description="Open Evidence Provenance Toolkit")
|
|
178
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
179
|
+
|
|
180
|
+
p_verify = sub.add_parser("verify", help="verify a bundle offline")
|
|
181
|
+
p_verify.add_argument("bundle", help="bundle .zip file")
|
|
182
|
+
p_verify.add_argument("--json", action="store_true", help="machine-readable output")
|
|
183
|
+
p_verify.add_argument("--trust-keys", help="JSON trust-keys file for signatures")
|
|
184
|
+
p_verify.set_defaults(func=cmd_verify)
|
|
185
|
+
|
|
186
|
+
p_bundle = sub.add_parser("bundle", help="build a bundle from a directory")
|
|
187
|
+
p_bundle.add_argument("directory", help="evidence directory to package")
|
|
188
|
+
p_bundle.add_argument("--output", required=True, help="output .zip path")
|
|
189
|
+
p_bundle.add_argument(
|
|
190
|
+
"--bundle-id", default="", help="bundle identifier (default: directory name)"
|
|
191
|
+
)
|
|
192
|
+
p_bundle.add_argument("--created-at", default="", help="ISO-8601 timestamp (default: now, UTC)")
|
|
193
|
+
p_bundle.add_argument("--sign", default="", help="Ed25519 private key PEM file for signing")
|
|
194
|
+
p_bundle.add_argument(
|
|
195
|
+
"--key-id", default="", help="key identifier label (required with --sign)"
|
|
196
|
+
)
|
|
197
|
+
p_bundle.set_defaults(func=cmd_bundle)
|
|
198
|
+
|
|
199
|
+
p_conf = sub.add_parser("conformance", help="run the conformance corpus")
|
|
200
|
+
p_conf.add_argument(
|
|
201
|
+
"--dir", default="", help="conformance corpus directory (default: packaged corpus)"
|
|
202
|
+
)
|
|
203
|
+
p_conf.add_argument("--json", action="store_true", help="machine-readable output")
|
|
204
|
+
p_conf.set_defaults(func=cmd_conformance)
|
|
205
|
+
|
|
206
|
+
p_schema = sub.add_parser("schema", help="inspect JSON Schemas")
|
|
207
|
+
p_schema.add_argument("action", help="'list' or a schema name")
|
|
208
|
+
p_schema.set_defaults(func=cmd_schema)
|
|
209
|
+
return parser
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
213
|
+
parser = build_parser()
|
|
214
|
+
try:
|
|
215
|
+
args = parser.parse_args(argv)
|
|
216
|
+
except SystemExit as exc:
|
|
217
|
+
return int(exc.code or 0)
|
|
218
|
+
try:
|
|
219
|
+
return int(args.func(args))
|
|
220
|
+
except BrokenPipeError:
|
|
221
|
+
return 1
|
|
222
|
+
except OeptError as exc:
|
|
223
|
+
print(f"oept: error: {exc}", file=sys.stderr)
|
|
224
|
+
return 2
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
raise SystemExit(main())
|
oept/conformance.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Conformance corpus runner.
|
|
2
|
+
|
|
3
|
+
Each fixture directory under the corpus root contains a ``bundle.zip``
|
|
4
|
+
(or ``bundle/`` directory) plus ``expected-result.json`` with at least a
|
|
5
|
+
``status`` field. The runner verifies every fixture and compares the
|
|
6
|
+
terminal status. Trust keys for signed fixtures live in the fixture
|
|
7
|
+
directory (``trust-keys.json``) and are TEST ONLY material.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .errors import OeptError
|
|
17
|
+
from .signing import TrustedKey, load_trust_keys
|
|
18
|
+
from .verify import verify_bundle, verify_bundle_dir
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def default_corpus_dir() -> Path:
|
|
22
|
+
"""Packaged conformance corpus (installed data or repo checkout)."""
|
|
23
|
+
try:
|
|
24
|
+
from importlib import resources
|
|
25
|
+
|
|
26
|
+
anchor = resources.files("oept.data") / "conformance"
|
|
27
|
+
try:
|
|
28
|
+
if anchor.is_dir():
|
|
29
|
+
return Path(str(anchor))
|
|
30
|
+
except (OSError, ValueError):
|
|
31
|
+
pass
|
|
32
|
+
except (ImportError, ModuleNotFoundError, TypeError):
|
|
33
|
+
pass
|
|
34
|
+
here = Path(__file__).resolve().parent
|
|
35
|
+
candidates = [
|
|
36
|
+
here.parent.parent / "conformance" / "fixtures",
|
|
37
|
+
Path(__file__).resolve().parents[2] / "conformance" / "fixtures",
|
|
38
|
+
]
|
|
39
|
+
for candidate in candidates:
|
|
40
|
+
if candidate.is_dir():
|
|
41
|
+
return candidate
|
|
42
|
+
raise OeptError("OEPT conformance corpus not found")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_conformance(corpus_dir: Path | None = None) -> dict[str, Any]:
|
|
46
|
+
"""Run every fixture. Returns {total, passed, failed, cases}."""
|
|
47
|
+
root = Path(corpus_dir) if corpus_dir else default_corpus_dir()
|
|
48
|
+
if not root.is_dir():
|
|
49
|
+
raise OeptError(f"conformance corpus is not a directory: {root}")
|
|
50
|
+
cases: list[tuple[str, str, str, bool]] = []
|
|
51
|
+
passed = 0
|
|
52
|
+
for fixture in sorted(p for p in root.iterdir() if p.is_dir()):
|
|
53
|
+
expected_path = fixture / "expected-result.json"
|
|
54
|
+
if not expected_path.exists():
|
|
55
|
+
raise OeptError(f"fixture {fixture.name} lacks expected-result.json")
|
|
56
|
+
expected = json.loads(expected_path.read_text(encoding="utf-8"))
|
|
57
|
+
want = expected.get("status")
|
|
58
|
+
trust_file = fixture / "trust-keys.json"
|
|
59
|
+
keys: list[TrustedKey] = []
|
|
60
|
+
if trust_file.exists():
|
|
61
|
+
keys = load_trust_keys(trust_file)
|
|
62
|
+
bundle_zip = fixture / "bundle.zip"
|
|
63
|
+
bundle_dir = fixture / "bundle"
|
|
64
|
+
if bundle_zip.exists():
|
|
65
|
+
got = verify_bundle(bundle_zip, keys).status
|
|
66
|
+
elif bundle_dir.is_dir():
|
|
67
|
+
got = verify_bundle_dir(bundle_dir, keys).status
|
|
68
|
+
else:
|
|
69
|
+
raise OeptError(f"fixture {fixture.name} has no bundle.zip/bundle/")
|
|
70
|
+
ok = got == want
|
|
71
|
+
passed += 1 if ok else 0
|
|
72
|
+
cases.append((fixture.name, got, str(want), ok))
|
|
73
|
+
total = len(cases)
|
|
74
|
+
return {"total": total, "passed": passed, "failed": total - passed, "cases": cases}
|
oept/data/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""OEPT package data (schemas, conformance corpus)."""
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|