tallylock 0.1.0a1__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.
- tallylock/__init__.py +3 -0
- tallylock/__main__.py +4 -0
- tallylock/_http.py +223 -0
- tallylock/anchors.py +193 -0
- tallylock/canonical.py +122 -0
- tallylock/chain.py +71 -0
- tallylock/cli.py +624 -0
- tallylock/data/conformance/README.md +66 -0
- tallylock/data/conformance/canonical.json +1 -0
- tallylock/data/conformance/cases/bad-artifact-bytes.json +1 -0
- tallylock/data/conformance/cases/bad-attempt.json +1 -0
- tallylock/data/conformance/cases/bad-base64.json +1 -0
- tallylock/data/conformance/cases/bad-bool-counter.json +1 -0
- tallylock/data/conformance/cases/bad-budget.json +1 -0
- tallylock/data/conformance/cases/bad-call-after-end.json +1 -0
- tallylock/data/conformance/cases/bad-call-before-start.json +1 -0
- tallylock/data/conformance/cases/bad-completed-null-score.json +1 -0
- tallylock/data/conformance/cases/bad-completed-reason.json +1 -0
- tallylock/data/conformance/cases/bad-core-duplicate.json +1 -0
- tallylock/data/conformance/cases/bad-core-float.json +1 -0
- tallylock/data/conformance/cases/bad-core-noncanonical.json +1 -0
- tallylock/data/conformance/cases/bad-core-unsafe.json +1 -0
- tallylock/data/conformance/cases/bad-cross-sample-retry.json +1 -0
- tallylock/data/conformance/cases/bad-dangling-call.json +1 -0
- tallylock/data/conformance/cases/bad-duplicate-call.json +1 -0
- tallylock/data/conformance/cases/bad-duplicate-start.json +1 -0
- tallylock/data/conformance/cases/bad-early-terminal.json +1 -0
- tallylock/data/conformance/cases/bad-envelope-field.json +1 -0
- tallylock/data/conformance/cases/bad-event-binding.json +1 -0
- tallylock/data/conformance/cases/bad-event-future.json +1 -0
- tallylock/data/conformance/cases/bad-extra-artifact.json +1 -0
- tallylock/data/conformance/cases/bad-false-math-all-hashes.json +1 -0
- tallylock/data/conformance/cases/bad-false-math.json +1 -0
- tallylock/data/conformance/cases/bad-fixture-dataset-pin.json +1 -0
- tallylock/data/conformance/cases/bad-fixture-denominator.json +1 -0
- tallylock/data/conformance/cases/bad-fixture-duplicate.json +1 -0
- tallylock/data/conformance/cases/bad-fixture-observed-type.json +1 -0
- tallylock/data/conformance/cases/bad-fixture-unknown-field.json +1 -0
- tallylock/data/conformance/cases/bad-future-event-kind.json +1 -0
- tallylock/data/conformance/cases/bad-future-fixture-dataset.json +1 -0
- tallylock/data/conformance/cases/bad-future-fixture-scores.json +1 -0
- tallylock/data/conformance/cases/bad-future-manifest-missing-result-format.json +1 -0
- tallylock/data/conformance/cases/bad-future-result-score-profile.json +1 -0
- tallylock/data/conformance/cases/bad-future-result-valid-manifest.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-artifact-rank.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-call-count.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-completed-size.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-event-over-limit.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-final-call.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-first-call.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-future-and-missing-event-format.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-future-missing-manifest-format.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-future-nonstring-event-format.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-future-nonstring-result-format.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-id.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-no-end-room.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-no-pinned-dataset.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-nonfinal-terminal.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-retry-position.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-sibling.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-size.json +1 -0
- tallylock/data/conformance/cases/bad-inclusion-unused-sibling.json +1 -0
- tallylock/data/conformance/cases/bad-json-depth.json +1 -0
- tallylock/data/conformance/cases/bad-json-duplicate.json +1 -0
- tallylock/data/conformance/cases/bad-json-float.json +1 -0
- tallylock/data/conformance/cases/bad-json-nonfinite.json +1 -0
- tallylock/data/conformance/cases/bad-json-surrogate.json +1 -0
- tallylock/data/conformance/cases/bad-json-unsafe.json +1 -0
- tallylock/data/conformance/cases/bad-json-utf8.json +1 -0
- tallylock/data/conformance/cases/bad-known-subtotal-overflow.json +1 -0
- tallylock/data/conformance/cases/bad-manifest-field-future-result.json +1 -0
- tallylock/data/conformance/cases/bad-manifest-future.json +1 -0
- tallylock/data/conformance/cases/bad-manifest-malformed.json +1 -0
- tallylock/data/conformance/cases/bad-manifest-missing-format.json +1 -0
- tallylock/data/conformance/cases/bad-manifest-semantics-future-result.json +1 -0
- tallylock/data/conformance/cases/bad-manifest-unknown-string.json +1 -0
- tallylock/data/conformance/cases/bad-missing-artifact.json +1 -0
- tallylock/data/conformance/cases/bad-missing-coverage.json +1 -0
- tallylock/data/conformance/cases/bad-missing-manifest-format-future-profile.json +1 -0
- tallylock/data/conformance/cases/bad-missing-result-format-future-profile.json +1 -0
- tallylock/data/conformance/cases/bad-path-traversal.json +1 -0
- tallylock/data/conformance/cases/bad-prefix-collision.json +1 -0
- tallylock/data/conformance/cases/bad-profile.json +1 -0
- tallylock/data/conformance/cases/bad-record-blank.json +1 -0
- tallylock/data/conformance/cases/bad-record-crlf.json +1 -0
- tallylock/data/conformance/cases/bad-record-digest.json +1 -0
- tallylock/data/conformance/cases/bad-record-no-lf.json +1 -0
- tallylock/data/conformance/cases/bad-record-root.json +1 -0
- tallylock/data/conformance/cases/bad-record-whitespace.json +1 -0
- tallylock/data/conformance/cases/bad-registration.json +1 -0
- tallylock/data/conformance/cases/bad-resources.json +1 -0
- tallylock/data/conformance/cases/bad-result-unknown-field.json +1 -0
- tallylock/data/conformance/cases/bad-retry-forward.json +1 -0
- tallylock/data/conformance/cases/bad-retry-self.json +1 -0
- tallylock/data/conformance/cases/bad-score-binding.json +1 -0
- tallylock/data/conformance/cases/bad-second-score-role.json +1 -0
- tallylock/data/conformance/cases/bad-terminal-reason.json +1 -0
- tallylock/data/conformance/cases/inclusion-artifact-unavailable.json +1 -0
- tallylock/data/conformance/cases/inclusion-call.json +1 -0
- tallylock/data/conformance/cases/inclusion-event-exact-limit.json +1 -0
- tallylock/data/conformance/cases/inclusion-multiple-pinned-dataset-copies.json +1 -0
- tallylock/data/conformance/cases/inclusion-open-failed.json +1 -0
- tallylock/data/conformance/cases/inclusion-terminal.json +1 -0
- tallylock/data/conformance/cases/run-artifacts-unicode.json +1 -0
- tallylock/data/conformance/cases/run-budget-unknown-total.json +1 -0
- tallylock/data/conformance/cases/run-empty-failure.json +1 -0
- tallylock/data/conformance/cases/run-fixture-exact.json +1 -0
- tallylock/data/conformance/cases/run-fixture-null-failure.json +1 -0
- tallylock/data/conformance/cases/run-multiple-pinned-dataset-copies.json +1 -0
- tallylock/data/conformance/cases/run-null-total-safe-subtotal.json +1 -0
- tallylock/data/conformance/cases/run-opaque-score.json +1 -0
- tallylock/data/conformance/cases/run-retry.json +1 -0
- tallylock/data/conformance/index.json +1 -0
- tallylock/data/conformance/trees.json +1 -0
- tallylock/data/examples/basic/README.md +19 -0
- tallylock/data/examples/basic/manifest.json +17 -0
- tallylock/data/examples/basic/outputs/metrics.json +1 -0
- tallylock/data/examples/basic/outputs/run-log.txt +2 -0
- tallylock/data/schemas/protocol-v1.json +166 -0
- tallylock/doctor.py +91 -0
- tallylock/errors.py +12 -0
- tallylock/evidence.py +305 -0
- tallylock/filesystem.py +720 -0
- tallylock/fixture.py +54 -0
- tallylock/ledger.py +494 -0
- tallylock/merkle.py +106 -0
- tallylock/paths.py +51 -0
- tallylock/records.py +228 -0
- tallylock/render.py +176 -0
- tallylock/schema.py +331 -0
- tallylock/scoring.py +72 -0
- tallylock/store.py +493 -0
- tallylock/tally.py +192 -0
- tallylock/timestamping.py +370 -0
- tallylock/transport.py +114 -0
- tallylock/verify.py +470 -0
- tallylock-0.1.0a1.dist-info/METADATA +89 -0
- tallylock-0.1.0a1.dist-info/RECORD +143 -0
- tallylock-0.1.0a1.dist-info/WHEEL +5 -0
- tallylock-0.1.0a1.dist-info/entry_points.txt +2 -0
- tallylock-0.1.0a1.dist-info/licenses/LICENSE +202 -0
- tallylock-0.1.0a1.dist-info/licenses/NOTICE +7 -0
- tallylock-0.1.0a1.dist-info/top_level.txt +1 -0
tallylock/__init__.py
ADDED
tallylock/__main__.py
ADDED
tallylock/_http.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Private one-request HTTP boundary, supervised by an absolute deadline."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import base64
|
|
6
|
+
import http.client
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import select
|
|
12
|
+
import ssl
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from urllib.parse import urlsplit
|
|
17
|
+
|
|
18
|
+
from .errors import TallyLockError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_MAX_REQUEST_IPC = 2 * 1024 * 1024
|
|
22
|
+
_MAX_REQUEST_BODY = 1024 * 1024
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Response:
|
|
27
|
+
status: int
|
|
28
|
+
body: bytes
|
|
29
|
+
retry_after: str | None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class NetworkError(TallyLockError):
|
|
33
|
+
def __init__(self):
|
|
34
|
+
super().__init__("HTTP network request failed or exceeded its deadline", code=5)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _perform(method, url, body, headers, limit, timeout):
|
|
38
|
+
connection = None
|
|
39
|
+
try:
|
|
40
|
+
parsed = urlsplit(url)
|
|
41
|
+
cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
|
|
42
|
+
options = {"timeout": timeout}
|
|
43
|
+
if parsed.scheme == "https":
|
|
44
|
+
options["context"] = ssl.create_default_context()
|
|
45
|
+
connection = cls(parsed.hostname, parsed.port, **options)
|
|
46
|
+
path = parsed.path or "/"
|
|
47
|
+
if parsed.query:
|
|
48
|
+
path += "?" + parsed.query
|
|
49
|
+
connection.request(method, path, body=body, headers=headers)
|
|
50
|
+
response = connection.getresponse()
|
|
51
|
+
lengths = response.headers.get_all("Content-Length", [])
|
|
52
|
+
encodings = response.headers.get_all("Transfer-Encoding", [])
|
|
53
|
+
if (len(lengths) > 1 or len(encodings) > 1 or lengths and encodings
|
|
54
|
+
or encodings and encodings[0].lower() != "chunked"):
|
|
55
|
+
raise ValueError("response framing")
|
|
56
|
+
expected = None
|
|
57
|
+
if lengths:
|
|
58
|
+
if re.fullmatch(r"[0-9]{1,16}", lengths[0]) is None:
|
|
59
|
+
raise ValueError("response framing")
|
|
60
|
+
expected = int(lengths[0])
|
|
61
|
+
if response.status == 200 and expected > limit:
|
|
62
|
+
raise ValueError("response limit")
|
|
63
|
+
retry = response.getheader("Retry-After")
|
|
64
|
+
if retry is not None and (len(retry) > 256 or not retry.isascii()):
|
|
65
|
+
retry = None
|
|
66
|
+
# Error/redirect bodies are neither useful evidence nor safe diagnostics.
|
|
67
|
+
# Keep them entirely inside the remote connection, outside our IPC.
|
|
68
|
+
data = response.read(limit + 1) if response.status == 200 else b""
|
|
69
|
+
if len(data) > limit:
|
|
70
|
+
raise ValueError("response limit")
|
|
71
|
+
if response.status == 200 and expected is not None and len(data) != expected:
|
|
72
|
+
raise ValueError("truncated response")
|
|
73
|
+
metadata = {"status": response.status, "retry_after": retry}
|
|
74
|
+
except http.client.RemoteDisconnected:
|
|
75
|
+
metadata, data = {"error": "network"}, b""
|
|
76
|
+
except (ssl.SSLError, http.client.HTTPException, ValueError):
|
|
77
|
+
metadata, data = {"error": "response"}, b""
|
|
78
|
+
except OSError:
|
|
79
|
+
metadata, data = {"error": "network"}, b""
|
|
80
|
+
except Exception:
|
|
81
|
+
metadata, data = {"error": "response"}, b""
|
|
82
|
+
finally:
|
|
83
|
+
if connection is not None:
|
|
84
|
+
connection.close()
|
|
85
|
+
return json.dumps(metadata, separators=(",", ":")).encode() + b"\n" + data
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _worker():
|
|
89
|
+
"""Static child entry: request data arrives exclusively through bounded stdin."""
|
|
90
|
+
try:
|
|
91
|
+
data = sys.stdin.buffer.read(_MAX_REQUEST_IPC + 1)
|
|
92
|
+
if len(data) > _MAX_REQUEST_IPC:
|
|
93
|
+
raise ValueError("request IPC limit")
|
|
94
|
+
value = json.loads(data)
|
|
95
|
+
body = base64.b64decode(value["body"], validate=True) if value["body"] is not None else None
|
|
96
|
+
output = _perform(value["method"], value["url"], body, value["headers"],
|
|
97
|
+
value["response_limit"], value["timeout"])
|
|
98
|
+
except Exception:
|
|
99
|
+
output = b'{"error":"response"}\n'
|
|
100
|
+
sys.stdout.buffer.write(output)
|
|
101
|
+
sys.stdout.buffer.flush()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def request(method, url, *, body, headers, response_limit, timeout_seconds=10,
|
|
105
|
+
deadline: float | None = None) -> Response:
|
|
106
|
+
"""Do one request; DNS, TLS, body reads and IPC share one wall-clock bound."""
|
|
107
|
+
try:
|
|
108
|
+
parsed = urlsplit(url)
|
|
109
|
+
valid = (parsed.scheme in {"http", "https"} and parsed.hostname
|
|
110
|
+
and parsed.username is None and parsed.password is None
|
|
111
|
+
and not parsed.fragment and not any(ord(c) <= 32 or ord(c) == 127 for c in url))
|
|
112
|
+
parsed.port
|
|
113
|
+
except (ValueError, TypeError):
|
|
114
|
+
valid = False
|
|
115
|
+
if not valid or method not in {"GET", "POST"}:
|
|
116
|
+
raise TallyLockError("invalid HTTP request", code=5)
|
|
117
|
+
if (body is not None and (not isinstance(body, bytes) or len(body) > _MAX_REQUEST_BODY)
|
|
118
|
+
or not isinstance(headers, dict) or len(headers) > 128
|
|
119
|
+
or any(not isinstance(k, str) or not isinstance(v, str) for k, v in headers.items())):
|
|
120
|
+
raise TallyLockError("invalid HTTP request payload", code=5)
|
|
121
|
+
if len(url) > 16384 or sum(len(k)+len(v) for k, v in headers.items()) > 65536:
|
|
122
|
+
raise TallyLockError("HTTP request metadata exceeds size limit", code=5)
|
|
123
|
+
if (type(response_limit) is not int or not 0 <= response_limit <= 32 * 1024 * 1024
|
|
124
|
+
or isinstance(timeout_seconds, bool) or not isinstance(timeout_seconds, (int, float))
|
|
125
|
+
or not math.isfinite(timeout_seconds) or timeout_seconds <= 0):
|
|
126
|
+
raise TallyLockError("invalid HTTP request bounds", code=5)
|
|
127
|
+
end = time.monotonic() + timeout_seconds
|
|
128
|
+
if deadline is not None:
|
|
129
|
+
if isinstance(deadline, bool) or not isinstance(deadline, (int, float)) or not math.isfinite(deadline):
|
|
130
|
+
raise TallyLockError("invalid absolute HTTP deadline", code=5)
|
|
131
|
+
end = min(end, deadline)
|
|
132
|
+
if end <= time.monotonic():
|
|
133
|
+
raise NetworkError()
|
|
134
|
+
payload = json.dumps({"method": method, "url": url,
|
|
135
|
+
"body": base64.b64encode(body).decode("ascii") if body is not None else None,
|
|
136
|
+
"headers": headers, "response_limit": response_limit,
|
|
137
|
+
"timeout": end-time.monotonic()}, separators=(",", ":")).encode()
|
|
138
|
+
if len(payload) > _MAX_REQUEST_IPC:
|
|
139
|
+
raise TallyLockError("HTTP request IPC exceeds size limit", code=5)
|
|
140
|
+
chunks = bytearray()
|
|
141
|
+
child = None
|
|
142
|
+
primary = None
|
|
143
|
+
closed_streams = set()
|
|
144
|
+
|
|
145
|
+
def close_once(name):
|
|
146
|
+
if name not in closed_streams:
|
|
147
|
+
closed_streams.add(name)
|
|
148
|
+
getattr(child, name).close()
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
child = subprocess.Popen([sys.executable, "-I", "-m", "tallylock._http"], stdin=subprocess.PIPE,
|
|
152
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, close_fds=True)
|
|
153
|
+
os.set_blocking(child.stdin.fileno(), False)
|
|
154
|
+
os.set_blocking(child.stdout.fileno(), False)
|
|
155
|
+
position = 0
|
|
156
|
+
while True:
|
|
157
|
+
remaining = end - time.monotonic()
|
|
158
|
+
if remaining <= 0:
|
|
159
|
+
raise NetworkError()
|
|
160
|
+
writes = [child.stdin.fileno()] if not child.stdin.closed else []
|
|
161
|
+
ready, writable, _ = select.select([child.stdout.fileno()], writes, [], remaining)
|
|
162
|
+
if not ready and not writable:
|
|
163
|
+
raise NetworkError()
|
|
164
|
+
if writable:
|
|
165
|
+
position += os.write(child.stdin.fileno(), payload[position:position+65536])
|
|
166
|
+
if position == len(payload):
|
|
167
|
+
close_once("stdin")
|
|
168
|
+
if not ready:
|
|
169
|
+
continue
|
|
170
|
+
chunk = os.read(child.stdout.fileno(), 65536)
|
|
171
|
+
if not chunk:
|
|
172
|
+
break
|
|
173
|
+
chunks.extend(chunk)
|
|
174
|
+
if len(chunks) > response_limit + 2048:
|
|
175
|
+
raise TallyLockError("HTTP response exceeds size limit", code=5)
|
|
176
|
+
header, separator, data = bytes(chunks).partition(b"\n")
|
|
177
|
+
if not separator:
|
|
178
|
+
raise TallyLockError("HTTP request failed", code=5)
|
|
179
|
+
metadata = json.loads(header)
|
|
180
|
+
if metadata.get("error") == "network":
|
|
181
|
+
raise NetworkError()
|
|
182
|
+
if "error" in metadata:
|
|
183
|
+
raise TallyLockError("HTTP response failed validation", code=5)
|
|
184
|
+
if metadata["status"] == 401:
|
|
185
|
+
raise TallyLockError("HTTP authentication failed", code=5)
|
|
186
|
+
return Response(metadata["status"], data, metadata["retry_after"])
|
|
187
|
+
except (OSError, ValueError, KeyError):
|
|
188
|
+
primary = TallyLockError("HTTP request failed", code=5)
|
|
189
|
+
raise primary from None
|
|
190
|
+
except BaseException as error:
|
|
191
|
+
primary = error
|
|
192
|
+
raise
|
|
193
|
+
finally:
|
|
194
|
+
if child is not None:
|
|
195
|
+
cleanup_failed = False
|
|
196
|
+
|
|
197
|
+
def cleanup(operation):
|
|
198
|
+
nonlocal cleanup_failed
|
|
199
|
+
try:
|
|
200
|
+
operation()
|
|
201
|
+
except BaseException:
|
|
202
|
+
cleanup_failed = True
|
|
203
|
+
|
|
204
|
+
# A close can release its descriptor and still raise. Attempt each
|
|
205
|
+
# close once, then finish child cleanup even after secondary errors.
|
|
206
|
+
cleanup(lambda: close_once("stdin"))
|
|
207
|
+
cleanup(lambda: close_once("stdout"))
|
|
208
|
+
cleanup(child.terminate)
|
|
209
|
+
try:
|
|
210
|
+
child.wait(timeout=.2)
|
|
211
|
+
except subprocess.TimeoutExpired:
|
|
212
|
+
cleanup(child.kill)
|
|
213
|
+
cleanup(child.wait)
|
|
214
|
+
except BaseException:
|
|
215
|
+
cleanup_failed = True
|
|
216
|
+
cleanup(child.kill)
|
|
217
|
+
cleanup(child.wait)
|
|
218
|
+
if cleanup_failed and primary is None:
|
|
219
|
+
raise TallyLockError("HTTP request cleanup failed", code=5) from None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if __name__ == "__main__":
|
|
223
|
+
_worker()
|
tallylock/anchors.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Bounded standard detached OTS inspection and explicit offline verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from bitcoin.core import CBlockHeader
|
|
8
|
+
from opentimestamps.core.notary import (
|
|
9
|
+
BitcoinBlockHeaderAttestation, PendingAttestation, TimeAttestation,
|
|
10
|
+
VerificationError,
|
|
11
|
+
)
|
|
12
|
+
from opentimestamps.core.op import CryptOp, Op, OpSHA256
|
|
13
|
+
from opentimestamps.core.serialize import BytesDeserializationContext, DeserializationError
|
|
14
|
+
from opentimestamps.core.timestamp import DetachedTimestampFile, Timestamp
|
|
15
|
+
|
|
16
|
+
from .canonical import MAX_SAFE_INTEGER
|
|
17
|
+
from .chain import ChainView
|
|
18
|
+
from .errors import TallyLockError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
MAX_PROOF_BYTES = 16 * 1024 * 1024
|
|
22
|
+
# Match the pinned upstream timestamp decoder's default recursion policy.
|
|
23
|
+
MAX_TIMESTAMP_DEPTH = 256
|
|
24
|
+
MAX_TIMESTAMP_ITEMS = 100000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _expected_digest(value: str) -> None:
|
|
28
|
+
if not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None:
|
|
29
|
+
raise TallyLockError("expected SHA256 must be exactly 64 lowercase hex characters")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _detached_header(ctx, expected_sha256: str) -> bytes:
|
|
33
|
+
ctx.assert_magic(DetachedTimestampFile.HEADER_MAGIC)
|
|
34
|
+
major = ctx.read_uint8()
|
|
35
|
+
if major != DetachedTimestampFile.MAJOR_VERSION:
|
|
36
|
+
raise TallyLockError(f"unsupported detached timestamp major version: {major}", code=4)
|
|
37
|
+
file_hash_op = CryptOp.deserialize(ctx)
|
|
38
|
+
if not isinstance(file_hash_op, OpSHA256):
|
|
39
|
+
raise TallyLockError("unsupported detached file hash algorithm", code=4)
|
|
40
|
+
digest = ctx.read_bytes(file_hash_op.DIGEST_LENGTH)
|
|
41
|
+
if digest.hex() != expected_sha256:
|
|
42
|
+
raise TallyLockError("detached timestamp digest differs from the expected core SHA256")
|
|
43
|
+
return digest
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _preflight_timestamp(ctx) -> None:
|
|
47
|
+
"""Walk upstream framing and bounds, without invoking any proof operation."""
|
|
48
|
+
items = 0
|
|
49
|
+
|
|
50
|
+
def count_item():
|
|
51
|
+
nonlocal items
|
|
52
|
+
items += 1
|
|
53
|
+
if items > MAX_TIMESTAMP_ITEMS:
|
|
54
|
+
raise TallyLockError("timestamp exceeds the aggregate item limit")
|
|
55
|
+
|
|
56
|
+
def node(depth):
|
|
57
|
+
if depth > MAX_TIMESTAMP_DEPTH:
|
|
58
|
+
raise TallyLockError("timestamp exceeds the depth limit")
|
|
59
|
+
count_item()
|
|
60
|
+
operations = set()
|
|
61
|
+
|
|
62
|
+
def entry(tag):
|
|
63
|
+
if tag == b"\x00":
|
|
64
|
+
count_item()
|
|
65
|
+
TimeAttestation.deserialize(ctx)
|
|
66
|
+
else:
|
|
67
|
+
operation = Op.deserialize_from_tag(ctx, tag)
|
|
68
|
+
if operation in operations:
|
|
69
|
+
raise TallyLockError("timestamp repeats an operation at the same node")
|
|
70
|
+
operations.add(operation)
|
|
71
|
+
node(depth + 1)
|
|
72
|
+
|
|
73
|
+
tag = ctx.read_bytes(1)
|
|
74
|
+
while tag == b"\xff":
|
|
75
|
+
entry(ctx.read_bytes(1))
|
|
76
|
+
tag = ctx.read_bytes(1)
|
|
77
|
+
entry(tag)
|
|
78
|
+
|
|
79
|
+
node(1)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load(data: bytes, expected_sha256: str) -> Timestamp:
|
|
83
|
+
_expected_digest(expected_sha256)
|
|
84
|
+
if not isinstance(data, bytes):
|
|
85
|
+
raise TallyLockError("detached timestamp must be bytes")
|
|
86
|
+
if len(data) > MAX_PROOF_BYTES:
|
|
87
|
+
raise TallyLockError("detached timestamp exceeds 16 MiB")
|
|
88
|
+
try:
|
|
89
|
+
ctx = BytesDeserializationContext(data)
|
|
90
|
+
_detached_header(ctx, expected_sha256)
|
|
91
|
+
_preflight_timestamp(ctx)
|
|
92
|
+
ctx.assert_eof()
|
|
93
|
+
# Only fully bounded framing reaches upstream operation evaluation.
|
|
94
|
+
ctx = BytesDeserializationContext(data)
|
|
95
|
+
digest = _detached_header(ctx, expected_sha256)
|
|
96
|
+
stamp = Timestamp.deserialize(ctx, digest, _recursion_limit=MAX_TIMESTAMP_DEPTH)
|
|
97
|
+
ctx.assert_eof()
|
|
98
|
+
return stamp
|
|
99
|
+
except (DeserializationError, ValueError) as error:
|
|
100
|
+
raise TallyLockError(f"invalid detached timestamp: {error}") from error
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_timestamp(data: bytes, commitment: bytes) -> Timestamp:
|
|
104
|
+
"""Bound raw calendar framing before evaluating standard upstream operations."""
|
|
105
|
+
if not isinstance(data, bytes) or len(data) > 10000:
|
|
106
|
+
raise TallyLockError("calendar timestamp exceeds its byte limit")
|
|
107
|
+
if not isinstance(commitment, bytes) or not 1 <= len(commitment) <= 4096:
|
|
108
|
+
raise TallyLockError("invalid calendar commitment")
|
|
109
|
+
try:
|
|
110
|
+
ctx = BytesDeserializationContext(data)
|
|
111
|
+
_preflight_timestamp(ctx)
|
|
112
|
+
ctx.assert_eof()
|
|
113
|
+
ctx = BytesDeserializationContext(data)
|
|
114
|
+
stamp = Timestamp.deserialize(ctx, commitment, _recursion_limit=MAX_TIMESTAMP_DEPTH)
|
|
115
|
+
ctx.assert_eof()
|
|
116
|
+
return stamp
|
|
117
|
+
except (DeserializationError, ValueError) as error:
|
|
118
|
+
raise TallyLockError("invalid raw calendar timestamp") from error
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _evidence(stamp: Timestamp):
|
|
122
|
+
bitcoin = []
|
|
123
|
+
pending = unsupported = 0
|
|
124
|
+
for message, attestation in stamp.all_attestations():
|
|
125
|
+
if isinstance(attestation, BitcoinBlockHeaderAttestation):
|
|
126
|
+
bitcoin.append((message, attestation))
|
|
127
|
+
elif isinstance(attestation, PendingAttestation):
|
|
128
|
+
pending += 1
|
|
129
|
+
else:
|
|
130
|
+
unsupported += 1
|
|
131
|
+
heights = sorted({attestation.height for _, attestation in bitcoin})
|
|
132
|
+
return bitcoin, heights, pending, unsupported
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def inspect(data: bytes, expected_sha256: str) -> dict:
|
|
136
|
+
stamp = _load(data, expected_sha256)
|
|
137
|
+
_, heights, pending, unsupported = _evidence(stamp)
|
|
138
|
+
return {
|
|
139
|
+
"format": "prove.ots-inspection.v1", "sha256": expected_sha256, "bytes": len(data),
|
|
140
|
+
"bitcoin_heights": heights, "pending_count": pending,
|
|
141
|
+
"unsupported_attestations": unsupported,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def verify(
|
|
146
|
+
data: bytes | None, expected_sha256: str, chain_view: ChainView | None,
|
|
147
|
+
*, minimum_confirmations: int = 6,
|
|
148
|
+
) -> dict:
|
|
149
|
+
_expected_digest(expected_sha256)
|
|
150
|
+
if type(minimum_confirmations) is not int or not 1 <= minimum_confirmations <= MAX_SAFE_INTEGER:
|
|
151
|
+
raise TallyLockError("minimum confirmations must be a positive safe integer")
|
|
152
|
+
if chain_view is not None and not isinstance(chain_view, ChainView):
|
|
153
|
+
raise TallyLockError("chain view must be an explicit prepared ChainView")
|
|
154
|
+
result = {
|
|
155
|
+
"state": "missing", "height": None, "hash": None, "time": None,
|
|
156
|
+
"confirmations": None, "required_heights": [],
|
|
157
|
+
"ignored_invalid_attestations": 0, "unsupported_attestations": 0,
|
|
158
|
+
}
|
|
159
|
+
if data is None:
|
|
160
|
+
return result
|
|
161
|
+
bitcoin, heights, pending, unsupported = _evidence(_load(data, expected_sha256))
|
|
162
|
+
result["required_heights"] = heights
|
|
163
|
+
result["unsupported_attestations"] = unsupported
|
|
164
|
+
valid = []
|
|
165
|
+
invalid = 0
|
|
166
|
+
if chain_view is not None:
|
|
167
|
+
for message, attestation in bitcoin:
|
|
168
|
+
block = chain_view.block(attestation.height)
|
|
169
|
+
if block is None:
|
|
170
|
+
continue
|
|
171
|
+
try:
|
|
172
|
+
attestation.verify_against_blockheader(message, CBlockHeader.deserialize(block.header))
|
|
173
|
+
except VerificationError:
|
|
174
|
+
invalid += 1
|
|
175
|
+
else:
|
|
176
|
+
valid.append(block)
|
|
177
|
+
if valid:
|
|
178
|
+
qualifying = [block for block in valid if block.confirmations >= minimum_confirmations]
|
|
179
|
+
selected = min(qualifying or valid, key=lambda block: block.height)
|
|
180
|
+
result.update(
|
|
181
|
+
state="anchored" if qualifying else "insufficient_confirmations",
|
|
182
|
+
height=selected.height, hash=selected.hash, time=selected.time,
|
|
183
|
+
confirmations=selected.confirmations, ignored_invalid_attestations=invalid,
|
|
184
|
+
)
|
|
185
|
+
elif invalid:
|
|
186
|
+
raise TallyLockError("no valid cached Bitcoin witness; a cached header contradicts the proof")
|
|
187
|
+
elif bitcoin:
|
|
188
|
+
result["state"] = "insufficient_chain"
|
|
189
|
+
elif pending:
|
|
190
|
+
result["state"] = "pending"
|
|
191
|
+
else:
|
|
192
|
+
raise TallyLockError("proof contains no supported Bitcoin or pending witness", code=4)
|
|
193
|
+
return result
|
tallylock/canonical.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Strict parsing and RFC 8785 serialization for protocol metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import rfc8785
|
|
10
|
+
|
|
11
|
+
from .errors import TallyLockError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
MAX_JSON_BYTES = 8 * 1024 * 1024
|
|
15
|
+
MAX_DEPTH = 64
|
|
16
|
+
MAX_SAFE_INTEGER = 2**53 - 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _fail(message: str) -> None:
|
|
20
|
+
raise TallyLockError(message)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _integer(value: str) -> int:
|
|
24
|
+
number = int(value)
|
|
25
|
+
if not -MAX_SAFE_INTEGER <= number <= MAX_SAFE_INTEGER:
|
|
26
|
+
_fail("JSON integers must be within the safe integer range")
|
|
27
|
+
return number
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _constant(value: str) -> None:
|
|
31
|
+
_fail(f"JSON constant {value!r} is not permitted")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
35
|
+
result: dict[str, Any] = {}
|
|
36
|
+
for key, value in pairs:
|
|
37
|
+
if key in result:
|
|
38
|
+
_fail(f"duplicate JSON key: {key!r}")
|
|
39
|
+
result[key] = value
|
|
40
|
+
return result
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _validate(value: Any, depth: int = 0) -> None:
|
|
44
|
+
if depth > MAX_DEPTH:
|
|
45
|
+
_fail("JSON nesting exceeds 64 levels")
|
|
46
|
+
if isinstance(value, float):
|
|
47
|
+
_fail("JSON floating-point values are not permitted")
|
|
48
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
49
|
+
if not -MAX_SAFE_INTEGER <= value <= MAX_SAFE_INTEGER:
|
|
50
|
+
_fail("JSON integers must be within the safe integer range")
|
|
51
|
+
return
|
|
52
|
+
if value is None or isinstance(value, (bool, str)):
|
|
53
|
+
if isinstance(value, str):
|
|
54
|
+
try:
|
|
55
|
+
value.encode("utf-8")
|
|
56
|
+
except UnicodeEncodeError as error:
|
|
57
|
+
raise TallyLockError("JSON strings must not contain surrogate code points") from error
|
|
58
|
+
return
|
|
59
|
+
if isinstance(value, list):
|
|
60
|
+
for item in value:
|
|
61
|
+
_validate(item, depth + 1)
|
|
62
|
+
return
|
|
63
|
+
if isinstance(value, dict):
|
|
64
|
+
for key, item in value.items():
|
|
65
|
+
if not isinstance(key, str):
|
|
66
|
+
_fail("JSON object keys must be strings")
|
|
67
|
+
_validate(key, depth + 1)
|
|
68
|
+
_validate(item, depth + 1)
|
|
69
|
+
return
|
|
70
|
+
_fail(f"value of type {type(value).__name__} is not JSON")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def loads(data: bytes | str) -> Any:
|
|
74
|
+
"""Parse bounded strict JSON without executing metadata content."""
|
|
75
|
+
if isinstance(data, bytes):
|
|
76
|
+
if len(data) > MAX_JSON_BYTES:
|
|
77
|
+
_fail("JSON input exceeds 8 MiB")
|
|
78
|
+
try:
|
|
79
|
+
text = data.decode("utf-8")
|
|
80
|
+
except UnicodeDecodeError as error:
|
|
81
|
+
raise TallyLockError("JSON input must be valid UTF-8") from error
|
|
82
|
+
elif isinstance(data, str):
|
|
83
|
+
try:
|
|
84
|
+
encoded = data.encode("utf-8")
|
|
85
|
+
except UnicodeEncodeError as error:
|
|
86
|
+
raise TallyLockError("JSON input must not contain surrogate code points") from error
|
|
87
|
+
if len(encoded) > MAX_JSON_BYTES:
|
|
88
|
+
_fail("JSON input exceeds 8 MiB")
|
|
89
|
+
text = data
|
|
90
|
+
else:
|
|
91
|
+
_fail("JSON input must be bytes or str")
|
|
92
|
+
try:
|
|
93
|
+
value = json.loads(
|
|
94
|
+
text,
|
|
95
|
+
parse_float=lambda _: _fail("JSON floating-point values are not permitted"),
|
|
96
|
+
parse_int=_integer,
|
|
97
|
+
parse_constant=_constant,
|
|
98
|
+
object_pairs_hook=_object,
|
|
99
|
+
)
|
|
100
|
+
except TallyLockError:
|
|
101
|
+
raise
|
|
102
|
+
except (RecursionError, TypeError, ValueError, json.JSONDecodeError) as error:
|
|
103
|
+
raise TallyLockError("invalid JSON") from error
|
|
104
|
+
_validate(value)
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def dumps(value: Any) -> bytes:
|
|
109
|
+
"""Serialize a validated JSON value using RFC 8785 canonical JSON."""
|
|
110
|
+
_validate(value)
|
|
111
|
+
try:
|
|
112
|
+
encoded = rfc8785.dumps(value)
|
|
113
|
+
except (TypeError, ValueError, OverflowError) as error:
|
|
114
|
+
raise TallyLockError("value cannot be serialized as canonical JSON") from error
|
|
115
|
+
if len(encoded) > MAX_JSON_BYTES:
|
|
116
|
+
_fail("canonical JSON exceeds 8 MiB")
|
|
117
|
+
return encoded
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def digest(value: Any) -> str:
|
|
121
|
+
"""Return the SHA-256 digest of the canonical JSON bytes for *value*."""
|
|
122
|
+
return hashlib.sha256(dumps(value)).hexdigest()
|
tallylock/chain.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Explicit verifier-supplied cache, with internal bindings checked offline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
from typing import Mapping
|
|
8
|
+
|
|
9
|
+
from bitcoin.core import CBlockHeader, b2lx
|
|
10
|
+
|
|
11
|
+
from . import canonical
|
|
12
|
+
from .canonical import MAX_SAFE_INTEGER
|
|
13
|
+
from .errors import TallyLockError
|
|
14
|
+
from .schema import validate_chain
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class CachedBlock:
|
|
19
|
+
height: int
|
|
20
|
+
hash: str
|
|
21
|
+
header: bytes
|
|
22
|
+
confirmations: int
|
|
23
|
+
time: int
|
|
24
|
+
merkle_root: bytes
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ChainView:
|
|
29
|
+
_blocks: Mapping[int, CachedBlock]
|
|
30
|
+
_exported_at: str
|
|
31
|
+
_source: str
|
|
32
|
+
_tip_height: int
|
|
33
|
+
_tip_hash: str
|
|
34
|
+
|
|
35
|
+
def block(self, height: int) -> CachedBlock | None:
|
|
36
|
+
"""Look up a checked cached header without consulting any external source."""
|
|
37
|
+
if type(height) is not int or not 0 <= height <= MAX_SAFE_INTEGER:
|
|
38
|
+
raise TallyLockError("block height must be a nonnegative safe integer")
|
|
39
|
+
return self._blocks.get(height)
|
|
40
|
+
|
|
41
|
+
def summary(self) -> dict:
|
|
42
|
+
return {
|
|
43
|
+
"exported_at": self._exported_at,
|
|
44
|
+
"source": self._source,
|
|
45
|
+
"tip": {"height": self._tip_height, "hash": self._tip_hash},
|
|
46
|
+
"trust": "external_explicit",
|
|
47
|
+
"freshness": "cached_only",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def prepare(value: dict) -> ChainView:
|
|
52
|
+
"""Snapshot a cache; consistency checks do not authenticate its provenance."""
|
|
53
|
+
# Apply the existing bounded JSON profile before copying untrusted nesting.
|
|
54
|
+
snapshot = canonical.loads(canonical.dumps(value))
|
|
55
|
+
validate_chain(snapshot)
|
|
56
|
+
blocks = {}
|
|
57
|
+
for height_text, block in snapshot["blocks"].items():
|
|
58
|
+
height = int(height_text)
|
|
59
|
+
raw = bytes.fromhex(block["header"])
|
|
60
|
+
# Schema checks exact length/encoding; upstream supplies parsing and hash.
|
|
61
|
+
parsed = CBlockHeader.deserialize(raw)
|
|
62
|
+
if b2lx(parsed.GetHash()) != block["hash"]:
|
|
63
|
+
raise TallyLockError(f"cached block {height} hash differs from its header")
|
|
64
|
+
blocks[height] = CachedBlock(
|
|
65
|
+
height, block["hash"], raw, block["confirmations"],
|
|
66
|
+
parsed.nTime, parsed.hashMerkleRoot,
|
|
67
|
+
)
|
|
68
|
+
return ChainView(
|
|
69
|
+
MappingProxyType(blocks), snapshot["exported_at"], snapshot["source"],
|
|
70
|
+
snapshot["tip"]["height"], snapshot["tip"]["hash"],
|
|
71
|
+
)
|