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.
Files changed (143) hide show
  1. tallylock/__init__.py +3 -0
  2. tallylock/__main__.py +4 -0
  3. tallylock/_http.py +223 -0
  4. tallylock/anchors.py +193 -0
  5. tallylock/canonical.py +122 -0
  6. tallylock/chain.py +71 -0
  7. tallylock/cli.py +624 -0
  8. tallylock/data/conformance/README.md +66 -0
  9. tallylock/data/conformance/canonical.json +1 -0
  10. tallylock/data/conformance/cases/bad-artifact-bytes.json +1 -0
  11. tallylock/data/conformance/cases/bad-attempt.json +1 -0
  12. tallylock/data/conformance/cases/bad-base64.json +1 -0
  13. tallylock/data/conformance/cases/bad-bool-counter.json +1 -0
  14. tallylock/data/conformance/cases/bad-budget.json +1 -0
  15. tallylock/data/conformance/cases/bad-call-after-end.json +1 -0
  16. tallylock/data/conformance/cases/bad-call-before-start.json +1 -0
  17. tallylock/data/conformance/cases/bad-completed-null-score.json +1 -0
  18. tallylock/data/conformance/cases/bad-completed-reason.json +1 -0
  19. tallylock/data/conformance/cases/bad-core-duplicate.json +1 -0
  20. tallylock/data/conformance/cases/bad-core-float.json +1 -0
  21. tallylock/data/conformance/cases/bad-core-noncanonical.json +1 -0
  22. tallylock/data/conformance/cases/bad-core-unsafe.json +1 -0
  23. tallylock/data/conformance/cases/bad-cross-sample-retry.json +1 -0
  24. tallylock/data/conformance/cases/bad-dangling-call.json +1 -0
  25. tallylock/data/conformance/cases/bad-duplicate-call.json +1 -0
  26. tallylock/data/conformance/cases/bad-duplicate-start.json +1 -0
  27. tallylock/data/conformance/cases/bad-early-terminal.json +1 -0
  28. tallylock/data/conformance/cases/bad-envelope-field.json +1 -0
  29. tallylock/data/conformance/cases/bad-event-binding.json +1 -0
  30. tallylock/data/conformance/cases/bad-event-future.json +1 -0
  31. tallylock/data/conformance/cases/bad-extra-artifact.json +1 -0
  32. tallylock/data/conformance/cases/bad-false-math-all-hashes.json +1 -0
  33. tallylock/data/conformance/cases/bad-false-math.json +1 -0
  34. tallylock/data/conformance/cases/bad-fixture-dataset-pin.json +1 -0
  35. tallylock/data/conformance/cases/bad-fixture-denominator.json +1 -0
  36. tallylock/data/conformance/cases/bad-fixture-duplicate.json +1 -0
  37. tallylock/data/conformance/cases/bad-fixture-observed-type.json +1 -0
  38. tallylock/data/conformance/cases/bad-fixture-unknown-field.json +1 -0
  39. tallylock/data/conformance/cases/bad-future-event-kind.json +1 -0
  40. tallylock/data/conformance/cases/bad-future-fixture-dataset.json +1 -0
  41. tallylock/data/conformance/cases/bad-future-fixture-scores.json +1 -0
  42. tallylock/data/conformance/cases/bad-future-manifest-missing-result-format.json +1 -0
  43. tallylock/data/conformance/cases/bad-future-result-score-profile.json +1 -0
  44. tallylock/data/conformance/cases/bad-future-result-valid-manifest.json +1 -0
  45. tallylock/data/conformance/cases/bad-inclusion-artifact-rank.json +1 -0
  46. tallylock/data/conformance/cases/bad-inclusion-call-count.json +1 -0
  47. tallylock/data/conformance/cases/bad-inclusion-completed-size.json +1 -0
  48. tallylock/data/conformance/cases/bad-inclusion-event-over-limit.json +1 -0
  49. tallylock/data/conformance/cases/bad-inclusion-final-call.json +1 -0
  50. tallylock/data/conformance/cases/bad-inclusion-first-call.json +1 -0
  51. tallylock/data/conformance/cases/bad-inclusion-future-and-missing-event-format.json +1 -0
  52. tallylock/data/conformance/cases/bad-inclusion-future-missing-manifest-format.json +1 -0
  53. tallylock/data/conformance/cases/bad-inclusion-future-nonstring-event-format.json +1 -0
  54. tallylock/data/conformance/cases/bad-inclusion-future-nonstring-result-format.json +1 -0
  55. tallylock/data/conformance/cases/bad-inclusion-id.json +1 -0
  56. tallylock/data/conformance/cases/bad-inclusion-no-end-room.json +1 -0
  57. tallylock/data/conformance/cases/bad-inclusion-no-pinned-dataset.json +1 -0
  58. tallylock/data/conformance/cases/bad-inclusion-nonfinal-terminal.json +1 -0
  59. tallylock/data/conformance/cases/bad-inclusion-retry-position.json +1 -0
  60. tallylock/data/conformance/cases/bad-inclusion-sibling.json +1 -0
  61. tallylock/data/conformance/cases/bad-inclusion-size.json +1 -0
  62. tallylock/data/conformance/cases/bad-inclusion-unused-sibling.json +1 -0
  63. tallylock/data/conformance/cases/bad-json-depth.json +1 -0
  64. tallylock/data/conformance/cases/bad-json-duplicate.json +1 -0
  65. tallylock/data/conformance/cases/bad-json-float.json +1 -0
  66. tallylock/data/conformance/cases/bad-json-nonfinite.json +1 -0
  67. tallylock/data/conformance/cases/bad-json-surrogate.json +1 -0
  68. tallylock/data/conformance/cases/bad-json-unsafe.json +1 -0
  69. tallylock/data/conformance/cases/bad-json-utf8.json +1 -0
  70. tallylock/data/conformance/cases/bad-known-subtotal-overflow.json +1 -0
  71. tallylock/data/conformance/cases/bad-manifest-field-future-result.json +1 -0
  72. tallylock/data/conformance/cases/bad-manifest-future.json +1 -0
  73. tallylock/data/conformance/cases/bad-manifest-malformed.json +1 -0
  74. tallylock/data/conformance/cases/bad-manifest-missing-format.json +1 -0
  75. tallylock/data/conformance/cases/bad-manifest-semantics-future-result.json +1 -0
  76. tallylock/data/conformance/cases/bad-manifest-unknown-string.json +1 -0
  77. tallylock/data/conformance/cases/bad-missing-artifact.json +1 -0
  78. tallylock/data/conformance/cases/bad-missing-coverage.json +1 -0
  79. tallylock/data/conformance/cases/bad-missing-manifest-format-future-profile.json +1 -0
  80. tallylock/data/conformance/cases/bad-missing-result-format-future-profile.json +1 -0
  81. tallylock/data/conformance/cases/bad-path-traversal.json +1 -0
  82. tallylock/data/conformance/cases/bad-prefix-collision.json +1 -0
  83. tallylock/data/conformance/cases/bad-profile.json +1 -0
  84. tallylock/data/conformance/cases/bad-record-blank.json +1 -0
  85. tallylock/data/conformance/cases/bad-record-crlf.json +1 -0
  86. tallylock/data/conformance/cases/bad-record-digest.json +1 -0
  87. tallylock/data/conformance/cases/bad-record-no-lf.json +1 -0
  88. tallylock/data/conformance/cases/bad-record-root.json +1 -0
  89. tallylock/data/conformance/cases/bad-record-whitespace.json +1 -0
  90. tallylock/data/conformance/cases/bad-registration.json +1 -0
  91. tallylock/data/conformance/cases/bad-resources.json +1 -0
  92. tallylock/data/conformance/cases/bad-result-unknown-field.json +1 -0
  93. tallylock/data/conformance/cases/bad-retry-forward.json +1 -0
  94. tallylock/data/conformance/cases/bad-retry-self.json +1 -0
  95. tallylock/data/conformance/cases/bad-score-binding.json +1 -0
  96. tallylock/data/conformance/cases/bad-second-score-role.json +1 -0
  97. tallylock/data/conformance/cases/bad-terminal-reason.json +1 -0
  98. tallylock/data/conformance/cases/inclusion-artifact-unavailable.json +1 -0
  99. tallylock/data/conformance/cases/inclusion-call.json +1 -0
  100. tallylock/data/conformance/cases/inclusion-event-exact-limit.json +1 -0
  101. tallylock/data/conformance/cases/inclusion-multiple-pinned-dataset-copies.json +1 -0
  102. tallylock/data/conformance/cases/inclusion-open-failed.json +1 -0
  103. tallylock/data/conformance/cases/inclusion-terminal.json +1 -0
  104. tallylock/data/conformance/cases/run-artifacts-unicode.json +1 -0
  105. tallylock/data/conformance/cases/run-budget-unknown-total.json +1 -0
  106. tallylock/data/conformance/cases/run-empty-failure.json +1 -0
  107. tallylock/data/conformance/cases/run-fixture-exact.json +1 -0
  108. tallylock/data/conformance/cases/run-fixture-null-failure.json +1 -0
  109. tallylock/data/conformance/cases/run-multiple-pinned-dataset-copies.json +1 -0
  110. tallylock/data/conformance/cases/run-null-total-safe-subtotal.json +1 -0
  111. tallylock/data/conformance/cases/run-opaque-score.json +1 -0
  112. tallylock/data/conformance/cases/run-retry.json +1 -0
  113. tallylock/data/conformance/index.json +1 -0
  114. tallylock/data/conformance/trees.json +1 -0
  115. tallylock/data/examples/basic/README.md +19 -0
  116. tallylock/data/examples/basic/manifest.json +17 -0
  117. tallylock/data/examples/basic/outputs/metrics.json +1 -0
  118. tallylock/data/examples/basic/outputs/run-log.txt +2 -0
  119. tallylock/data/schemas/protocol-v1.json +166 -0
  120. tallylock/doctor.py +91 -0
  121. tallylock/errors.py +12 -0
  122. tallylock/evidence.py +305 -0
  123. tallylock/filesystem.py +720 -0
  124. tallylock/fixture.py +54 -0
  125. tallylock/ledger.py +494 -0
  126. tallylock/merkle.py +106 -0
  127. tallylock/paths.py +51 -0
  128. tallylock/records.py +228 -0
  129. tallylock/render.py +176 -0
  130. tallylock/schema.py +331 -0
  131. tallylock/scoring.py +72 -0
  132. tallylock/store.py +493 -0
  133. tallylock/tally.py +192 -0
  134. tallylock/timestamping.py +370 -0
  135. tallylock/transport.py +114 -0
  136. tallylock/verify.py +470 -0
  137. tallylock-0.1.0a1.dist-info/METADATA +89 -0
  138. tallylock-0.1.0a1.dist-info/RECORD +143 -0
  139. tallylock-0.1.0a1.dist-info/WHEEL +5 -0
  140. tallylock-0.1.0a1.dist-info/entry_points.txt +2 -0
  141. tallylock-0.1.0a1.dist-info/licenses/LICENSE +202 -0
  142. tallylock-0.1.0a1.dist-info/licenses/NOTICE +7 -0
  143. tallylock-0.1.0a1.dist-info/top_level.txt +1 -0
tallylock/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Standalone evaluation receipts and offline evidence verification."""
2
+
3
+ __version__ = "0.1.0a1"
tallylock/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Dispatch ``python -m tallylock`` through the installed command interface."""
2
+ from .cli import main
3
+
4
+ raise SystemExit(main())
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
+ )