tallwright-core 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tallwright_core/__init__.py +93 -0
- tallwright_core/canonical.py +88 -0
- tallwright_core/document.py +105 -0
- tallwright_core/hashing.py +207 -0
- tallwright_core/merkle.py +186 -0
- tallwright_core/py.typed +0 -0
- tallwright_core/schema.py +176 -0
- tallwright_core/write.py +36 -0
- tallwright_core-0.1.0.dist-info/METADATA +42 -0
- tallwright_core-0.1.0.dist-info/RECORD +13 -0
- tallwright_core-0.1.0.dist-info/WHEEL +4 -0
- tallwright_core-0.1.0.dist-info/licenses/LICENSE +202 -0
- tallwright_core-0.1.0.dist-info/licenses/NOTICE +6 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""tallwright_core — shared record schema, content-addressing, and Merkle primitives.
|
|
3
|
+
|
|
4
|
+
The dependency-light foundation every other Tallwright package builds on. No
|
|
5
|
+
networked or external-service code lives here (no TSA, transparency log, or storage).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .document import AmbiguousDocument, DocumentTooDeep, load_document
|
|
11
|
+
from .write import write_document
|
|
12
|
+
from .canonical import (
|
|
13
|
+
MAX_CANONICAL_DEPTH,
|
|
14
|
+
MAX_CANONICAL_NODES,
|
|
15
|
+
CanonicalizationError,
|
|
16
|
+
canonicalize,
|
|
17
|
+
)
|
|
18
|
+
from .hashing import (
|
|
19
|
+
content_address,
|
|
20
|
+
decode_content_address,
|
|
21
|
+
decode_multihash,
|
|
22
|
+
multihash,
|
|
23
|
+
MalformedSalt,
|
|
24
|
+
SALT_LEN,
|
|
25
|
+
address_from_digest,
|
|
26
|
+
opens_address,
|
|
27
|
+
sha256,
|
|
28
|
+
verify,
|
|
29
|
+
)
|
|
30
|
+
from .merkle import (
|
|
31
|
+
MerkleTree,
|
|
32
|
+
consistency_proof,
|
|
33
|
+
inclusion_proof,
|
|
34
|
+
leaf_hash,
|
|
35
|
+
merkle_root,
|
|
36
|
+
node_hash,
|
|
37
|
+
verify_consistency,
|
|
38
|
+
verify_inclusion,
|
|
39
|
+
)
|
|
40
|
+
from .schema import (
|
|
41
|
+
CANONICALIZATION_METHOD,
|
|
42
|
+
HASH_ALGORITHM,
|
|
43
|
+
SCHEMA_VERSION,
|
|
44
|
+
AnchorReceipt,
|
|
45
|
+
EventType,
|
|
46
|
+
GapManifest,
|
|
47
|
+
Record,
|
|
48
|
+
link,
|
|
49
|
+
verify_chain,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
# reading a document, and replacing one
|
|
54
|
+
"load_document",
|
|
55
|
+
"AmbiguousDocument",
|
|
56
|
+
"DocumentTooDeep",
|
|
57
|
+
"write_document",
|
|
58
|
+
# canonicalization
|
|
59
|
+
"canonicalize",
|
|
60
|
+
"CanonicalizationError",
|
|
61
|
+
"MAX_CANONICAL_DEPTH",
|
|
62
|
+
"MAX_CANONICAL_NODES",
|
|
63
|
+
# hashing
|
|
64
|
+
"sha256",
|
|
65
|
+
"multihash",
|
|
66
|
+
"decode_multihash",
|
|
67
|
+
"content_address",
|
|
68
|
+
"MalformedSalt",
|
|
69
|
+
"SALT_LEN",
|
|
70
|
+
"address_from_digest",
|
|
71
|
+
"opens_address",
|
|
72
|
+
"decode_content_address",
|
|
73
|
+
"verify",
|
|
74
|
+
# merkle
|
|
75
|
+
"leaf_hash",
|
|
76
|
+
"node_hash",
|
|
77
|
+
"merkle_root",
|
|
78
|
+
"inclusion_proof",
|
|
79
|
+
"consistency_proof",
|
|
80
|
+
"verify_inclusion",
|
|
81
|
+
"verify_consistency",
|
|
82
|
+
"MerkleTree",
|
|
83
|
+
# schema
|
|
84
|
+
"EventType",
|
|
85
|
+
"GapManifest",
|
|
86
|
+
"AnchorReceipt",
|
|
87
|
+
"Record",
|
|
88
|
+
"link",
|
|
89
|
+
"verify_chain",
|
|
90
|
+
"SCHEMA_VERSION",
|
|
91
|
+
"HASH_ALGORITHM",
|
|
92
|
+
"CANONICALIZATION_METHOD",
|
|
93
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Hardened RFC 8785 (JCS) canonicalization.
|
|
3
|
+
|
|
4
|
+
Wraps ``rfc8785.dumps`` with fail-closed guards so a hostile or malformed input becomes
|
|
5
|
+
a *typed, catchable* error instead of a crash, a silent drop, or non-deterministic bytes:
|
|
6
|
+
|
|
7
|
+
- **non-finite floats** (NaN / +-Infinity) are rejected — RFC 8785 has no representation
|
|
8
|
+
for them, and letting one through would make a hash irreproducible;
|
|
9
|
+
- **nesting depth is bounded** (``MAX_CANONICAL_DEPTH``) so a deeply-nested structure
|
|
10
|
+
cannot exhaust the interpreter stack (a cheap capture-path DoS);
|
|
11
|
+
- **total work is bounded** (``MAX_CANONICAL_NODES``) so a shallow but shared-reference
|
|
12
|
+
DAG cannot expand to exponentially many nodes and hang the serializer;
|
|
13
|
+
- any residual library-level failure (unserializable type, non-string key, recursion)
|
|
14
|
+
is re-raised as :class:`CanonicalizationError`.
|
|
15
|
+
|
|
16
|
+
For every input **within the depth / total-work bounds**, the output is byte-for-byte
|
|
17
|
+
identical to a raw ``rfc8785.dumps`` call — so record hashes are unchanged. Inputs that
|
|
18
|
+
exceed those bounds (or that ``rfc8785`` itself rejects) raise a TYPED error rather than hanging or
|
|
19
|
+
silently producing a non-reproducible value. What the capture layer then does depends on its mode:
|
|
20
|
+
under the SHIPPED DEFAULT (``fail_closed=False``) the error is counted and sealed as an auditable
|
|
21
|
+
rupture marker, and the agent keeps serving; with ``fail_closed=True`` it propagates and NOTHING is
|
|
22
|
+
sealed — no gap record, no rupture marker, and no unrecorded action either.
|
|
23
|
+
The bounds are set far above any real record or tool payload, so this only ever trips a
|
|
24
|
+
pathological/hostile input.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import math
|
|
30
|
+
from collections.abc import Mapping, Sequence
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
import rfc8785
|
|
34
|
+
|
|
35
|
+
# JSON nesting deeper than this is rejected before serialization. Real records and tool
|
|
36
|
+
# payloads are shallow; this only trips a pathological/hostile input.
|
|
37
|
+
MAX_CANONICAL_DEPTH = 64
|
|
38
|
+
# Upper bound on the total number of values visited during the guard walk. Bounds total
|
|
39
|
+
# work so a shared-reference DAG (shallow but exponentially wide when traversed as a tree)
|
|
40
|
+
# is rejected quickly instead of hanging. JSON from ``model_dump`` is a tree (node count ~=
|
|
41
|
+
# payload size), so this only rejects a genuinely enormous or hostile-DAG payload — which
|
|
42
|
+
# then becomes an auditable gap, never an unbounded hang.
|
|
43
|
+
MAX_CANONICAL_NODES = 1_000_000
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CanonicalizationError(ValueError):
|
|
47
|
+
"""An input cannot be deterministically canonicalized under RFC 8785 (JCS)."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def canonicalize(obj: Any) -> bytes:
|
|
51
|
+
"""Return the RFC 8785 (JCS) canonical bytes of ``obj``, or raise CanonicalizationError.
|
|
52
|
+
|
|
53
|
+
Guards run *before* serialization so depth/finiteness/total-work violations are reported
|
|
54
|
+
without ever entering the C serializer with a hostile input. For any already-canonicalizable
|
|
55
|
+
input the output is byte-identical to a raw ``rfc8785.dumps`` call.
|
|
56
|
+
"""
|
|
57
|
+
remaining = MAX_CANONICAL_NODES
|
|
58
|
+
|
|
59
|
+
def guard(node: Any, depth: int) -> None:
|
|
60
|
+
nonlocal remaining
|
|
61
|
+
remaining -= 1
|
|
62
|
+
if remaining < 0:
|
|
63
|
+
raise CanonicalizationError(f"input exceeds MAX_CANONICAL_NODES={MAX_CANONICAL_NODES}")
|
|
64
|
+
if depth > MAX_CANONICAL_DEPTH:
|
|
65
|
+
raise CanonicalizationError(f"nesting exceeds MAX_CANONICAL_DEPTH={MAX_CANONICAL_DEPTH}")
|
|
66
|
+
if isinstance(node, bool):
|
|
67
|
+
return # bool before int/float; nothing to recurse into
|
|
68
|
+
if isinstance(node, float):
|
|
69
|
+
if not math.isfinite(node):
|
|
70
|
+
raise CanonicalizationError(f"non-finite float {node!r} has no RFC 8785 representation")
|
|
71
|
+
return
|
|
72
|
+
if isinstance(node, Mapping):
|
|
73
|
+
for value in node.values():
|
|
74
|
+
guard(value, depth + 1)
|
|
75
|
+
return
|
|
76
|
+
if isinstance(node, (str, bytes)):
|
|
77
|
+
return # sized but not a container to descend
|
|
78
|
+
if isinstance(node, Sequence):
|
|
79
|
+
for item in node:
|
|
80
|
+
guard(item, depth + 1)
|
|
81
|
+
|
|
82
|
+
guard(obj, 0)
|
|
83
|
+
try:
|
|
84
|
+
return rfc8785.dumps(obj)
|
|
85
|
+
except (ValueError, TypeError, RecursionError, OverflowError) as exc:
|
|
86
|
+
# rfc8785's own IntegerDomainError / etc. subclass ValueError; normalize them all
|
|
87
|
+
# (and any residual serializer failure) to one typed, catchable error.
|
|
88
|
+
raise CanonicalizationError(str(exc)) from exc
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Reading a document whose meaning does not depend on who reads it.
|
|
3
|
+
|
|
4
|
+
JSON permits an object to carry the same key twice, and every parser resolves that its own way:
|
|
5
|
+
``json.loads`` keeps the LAST, a person reading the file sees the FIRST, and another tool may see
|
|
6
|
+
either. A file with two readings is not a document — and an exhibit is a document before it is
|
|
7
|
+
anything else.
|
|
8
|
+
|
|
9
|
+
What that buys a producer, measured against this project's own golden vector before this module
|
|
10
|
+
existed: insert ``"action_type":"password_reset",`` in front of the real ``"action_type":"wire_transfer"``
|
|
11
|
+
and the parsed object is unchanged, so the canonicalization is unchanged, so **every signature still
|
|
12
|
+
verifies**. The court-facing tool prints its PASS lines for a file that reads as a different action
|
|
13
|
+
than the one it authenticates. No key is forged; the ambiguity is in the format.
|
|
14
|
+
|
|
15
|
+
So it is refused, at the door, wherever this project reads a document it will act on.
|
|
16
|
+
|
|
17
|
+
**Nor is a document read past a depth nobody writes.** ``json.loads`` recurses once per level of
|
|
18
|
+
nesting, and two hundred thousand ``[`` made it raise RecursionError — which is not a ValueError, so it
|
|
19
|
+
passed every reader's "unreadable" branch: the verifier printed a traceback and exited 1, the code for
|
|
20
|
+
evidence that was tampered with, and the same program in a browser lost its WebAssembly worker, whose
|
|
21
|
+
stack runs out before Python's recursion check does. The depth is therefore counted BEFORE anything
|
|
22
|
+
recurses, and a document past :data:`MAX_DOCUMENT_DEPTH` is refused as the ValueError it always should
|
|
23
|
+
have been.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import re
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from .canonical import MAX_CANONICAL_DEPTH
|
|
33
|
+
|
|
34
|
+
__all__ = ["AmbiguousDocument", "DocumentTooDeep", "MAX_DOCUMENT_DEPTH", "load_document"]
|
|
35
|
+
|
|
36
|
+
#: The deepest a document read here may nest, its own outermost object or array counting as one. An
|
|
37
|
+
#: exhibit nests 6 levels, a policy 4 and a disclosure 1; the deepest object this project canonicalises
|
|
38
|
+
#: nests MAX_CANONICAL_DEPTH + 1, and this leaves room for it inside an envelope as deep again. CPython
|
|
39
|
+
#: 3.12 raises RecursionError near 3,000 levels and a browser's WebAssembly stack gives out sooner, so
|
|
40
|
+
#: the bound sits far below both, and far above anything real.
|
|
41
|
+
MAX_DOCUMENT_DEPTH = 2 * MAX_CANONICAL_DEPTH
|
|
42
|
+
|
|
43
|
+
#: One JSON string — closed, or running to the end of a text that never closes it — or one bracket. The
|
|
44
|
+
#: possessive quantifiers consume a string's run once and never give it back, so no input makes the scan
|
|
45
|
+
#: restart inside a string it has passed: the scan is linear in the text.
|
|
46
|
+
_TOKENS = re.compile(r'"(?:[^"\\]++|\\.)*+(?:"|\\?\Z)|[\[\]{}]', re.DOTALL)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AmbiguousDocument(ValueError):
|
|
50
|
+
"""A document that reads one way to one parser and another way to the next."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DocumentTooDeep(ValueError):
|
|
54
|
+
"""A document nested deeper than anything this project writes, refused before it is parsed."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _no_repeats(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
58
|
+
seen: dict[str, Any] = {}
|
|
59
|
+
for key, value in pairs:
|
|
60
|
+
if key in seen:
|
|
61
|
+
raise AmbiguousDocument(
|
|
62
|
+
f"the key {key!r} appears twice in one object. A reader of this file and a parser "
|
|
63
|
+
f"of it would not agree on what it says, so it is not read here."
|
|
64
|
+
)
|
|
65
|
+
seen[key] = value
|
|
66
|
+
return seen
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _deeper_than(text: str, bound: int) -> bool:
|
|
70
|
+
"""Whether any point of ``text`` lies inside more than ``bound`` objects and arrays.
|
|
71
|
+
|
|
72
|
+
Brackets inside strings are not nesting, and an escaped quote does not end a string. Malformed text
|
|
73
|
+
is not this function's to judge: it counts what it finds, and the parser refuses the rest.
|
|
74
|
+
"""
|
|
75
|
+
if text.count("[") + text.count("{") <= bound:
|
|
76
|
+
return False # a text cannot nest deeper than it has openings; nearly every document stops here
|
|
77
|
+
depth = 0
|
|
78
|
+
for token in _TOKENS.finditer(text):
|
|
79
|
+
first = text[token.start()]
|
|
80
|
+
if first in "[{":
|
|
81
|
+
depth += 1
|
|
82
|
+
if depth > bound:
|
|
83
|
+
return True
|
|
84
|
+
elif first in "]}":
|
|
85
|
+
depth -= 1
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_document(text: str | bytes) -> Any:
|
|
90
|
+
"""``json.loads``, refusing any object that carries a key twice and any document nested deeper than
|
|
91
|
+
:data:`MAX_DOCUMENT_DEPTH`.
|
|
92
|
+
|
|
93
|
+
Raises :class:`AmbiguousDocument` for a repeated key, :class:`DocumentTooDeep` for the nesting, and
|
|
94
|
+
``json.JSONDecodeError`` — a ``ValueError`` like both — for anything else malformed, so a caller that
|
|
95
|
+
already handles unreadable input keeps handling it. Bytes are decoded the way ``json.loads`` decodes
|
|
96
|
+
them.
|
|
97
|
+
"""
|
|
98
|
+
source = text.decode(json.detect_encoding(text), "surrogatepass") if isinstance(text, bytes) else text
|
|
99
|
+
if _deeper_than(source, MAX_DOCUMENT_DEPTH):
|
|
100
|
+
raise DocumentTooDeep(
|
|
101
|
+
f"it is nested deeper than {MAX_DOCUMENT_DEPTH} levels. Nothing this project writes comes "
|
|
102
|
+
f"near that, and a parser following it would run out of stack before it ran out of "
|
|
103
|
+
f"document, so it is not read here."
|
|
104
|
+
)
|
|
105
|
+
return json.loads(source, object_pairs_hook=_no_repeats)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Content-addressing and self-describing multihash (SHA-256).
|
|
3
|
+
|
|
4
|
+
Zero external dependencies. SHA-256 is carried as a self-describing *multihash*
|
|
5
|
+
so a digest records *which* algorithm produced it — the algorithm-agility
|
|
6
|
+
property the seal spec needs for long-term verifiability. Only sha2-256 is supported in V1;
|
|
7
|
+
the structure leaves room for a future migration (e.g. to an SHA-3 / longer hash)
|
|
8
|
+
without breaking stored proofs.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
|
|
16
|
+
# multicodec code for sha2-256 is 0x12; its digest length is 32 bytes (0x20).
|
|
17
|
+
SHA2_256_CODE = 0x12
|
|
18
|
+
SHA2_256_LENGTH = 32
|
|
19
|
+
# multibase prefix for base16, lower-case (RFC 4648). Keeps content addresses
|
|
20
|
+
# trivially inspectable and verifiable.
|
|
21
|
+
_MULTIBASE_BASE16_LOWER = "f"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sha256(data: bytes) -> bytes:
|
|
25
|
+
"""Return the raw 32-byte SHA-256 digest of ``data``."""
|
|
26
|
+
return hashlib.sha256(data).digest()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _uvarint_encode(value: int) -> bytes:
|
|
30
|
+
if value < 0:
|
|
31
|
+
raise ValueError("uvarint cannot encode a negative value")
|
|
32
|
+
out = bytearray()
|
|
33
|
+
while True:
|
|
34
|
+
byte = value & 0x7F
|
|
35
|
+
value >>= 7
|
|
36
|
+
if value:
|
|
37
|
+
out.append(byte | 0x80)
|
|
38
|
+
else:
|
|
39
|
+
out.append(byte)
|
|
40
|
+
return bytes(out)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _uvarint_decode(buf: bytes, offset: int) -> tuple[int, int]:
|
|
44
|
+
result = 0
|
|
45
|
+
shift = 0
|
|
46
|
+
pos = offset
|
|
47
|
+
while True:
|
|
48
|
+
if pos >= len(buf):
|
|
49
|
+
raise ValueError("truncated uvarint")
|
|
50
|
+
byte = buf[pos]
|
|
51
|
+
pos += 1
|
|
52
|
+
result |= (byte & 0x7F) << shift
|
|
53
|
+
if not byte & 0x80:
|
|
54
|
+
return result, pos
|
|
55
|
+
shift += 7
|
|
56
|
+
if shift > 63:
|
|
57
|
+
raise ValueError("uvarint too long")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def multihash(data: bytes, *, algo: str = "sha2-256") -> bytes:
|
|
61
|
+
"""Return the self-describing multihash of ``data``.
|
|
62
|
+
|
|
63
|
+
Layout: ``uvarint(code) || uvarint(length) || digest``.
|
|
64
|
+
"""
|
|
65
|
+
if algo != "sha2-256":
|
|
66
|
+
raise ValueError(f"unsupported hash algorithm: {algo!r}")
|
|
67
|
+
digest = sha256(data)
|
|
68
|
+
return _uvarint_encode(SHA2_256_CODE) + _uvarint_encode(len(digest)) + digest
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def decode_multihash(mh: bytes) -> tuple[int, int, bytes]:
|
|
72
|
+
"""Return ``(code, length, digest)`` from a multihash, validating length."""
|
|
73
|
+
code, pos = _uvarint_decode(mh, 0)
|
|
74
|
+
length, pos = _uvarint_decode(mh, pos)
|
|
75
|
+
digest = mh[pos:]
|
|
76
|
+
if len(digest) != length:
|
|
77
|
+
raise ValueError("multihash length does not match its declared length")
|
|
78
|
+
return code, length, digest
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def content_address(data: bytes) -> str:
|
|
82
|
+
"""Return a multibase (base16-lower) content address for ``data``.
|
|
83
|
+
|
|
84
|
+
Used for ``payload_hash`` and for the record chain address. NOT for ``record_id``, which the
|
|
85
|
+
recorder mints as a UUID4 and the reference pipeline sets to a human label.
|
|
86
|
+
"""
|
|
87
|
+
return _MULTIBASE_BASE16_LOWER + multihash(data).hex()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def address_from_digest(digest: bytes) -> str:
|
|
91
|
+
"""The same content address as :func:`content_address`, from a digest already computed.
|
|
92
|
+
|
|
93
|
+
For data that arrives in pieces and must not be held whole. A streamed response body is the
|
|
94
|
+
case this exists for: the transport adapter folds each chunk into a ``hashlib`` object and keeps
|
|
95
|
+
only the prefix it is allowed to seal, so a multi-gigabyte download costs the recorder a
|
|
96
|
+
constant amount of memory instead of twice the body.
|
|
97
|
+
|
|
98
|
+
The knowledge of what an address LOOKS like stays in this module, which is the point. A caller
|
|
99
|
+
assembling the multibase prefix and the multicodec header itself would be a second place that
|
|
100
|
+
knows the format, and a second place is a place that one day answers differently.
|
|
101
|
+
"""
|
|
102
|
+
if len(digest) != SHA2_256_LENGTH:
|
|
103
|
+
raise ValueError(
|
|
104
|
+
f"expected a {SHA2_256_LENGTH}-byte sha2-256 digest, got {len(digest)} bytes"
|
|
105
|
+
)
|
|
106
|
+
header = _uvarint_encode(SHA2_256_CODE) + _uvarint_encode(SHA2_256_LENGTH)
|
|
107
|
+
return _MULTIBASE_BASE16_LOWER + (header + digest).hex()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
#: How long a per-record salt is, everywhere, without exception.
|
|
111
|
+
#:
|
|
112
|
+
#: It is not a tuning knob. ``SHA-256(salt ‖ plaintext)`` commits to the CONCATENATION and not to
|
|
113
|
+
#: the SPLIT, so if the length were free, every ``k`` would give a second valid reading:
|
|
114
|
+
#: ``(salt ‖ plaintext[:k], plaintext[k:])`` opens the very same address. An operator could then
|
|
115
|
+
#: disclose a TRUNCATED payload — pushing the amount, the account, the instruction into what they
|
|
116
|
+
#: call the salt — and a court's own verifier would confirm it opens. Because RFC 8785 sorts the
|
|
117
|
+
#: keys, the choppable prefix is deterministic and starts with the fields most worth withholding.
|
|
118
|
+
#:
|
|
119
|
+
#: A fixed length removes the second reading: there is exactly one way to cut 32 bytes off the front.
|
|
120
|
+
#: Found 2026-08-25 by adversarial review, demonstrated against a real record before it was fixed.
|
|
121
|
+
SALT_LEN = 32
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class MalformedSalt(ValueError):
|
|
125
|
+
"""A salt that is not exactly :data:`SALT_LEN` bytes. Not a mismatch — an unreadable disclosure.
|
|
126
|
+
|
|
127
|
+
Kept distinct because the two say completely different things to a court. "This disclosure does
|
|
128
|
+
not open the address" accuses the operator of giving a false account of their own record. "This
|
|
129
|
+
file is not in the format" says nothing about the record at all, and rendering the second as the
|
|
130
|
+
first is exactly the kind of overstatement this codebase refuses everywhere else.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def opens_address(address: str, salt: bytes, plaintext: bytes) -> bool:
|
|
135
|
+
"""Does this salt and this plaintext produce that committed address?
|
|
136
|
+
|
|
137
|
+
The whole check a court performs when the archive holds no content — the operator discloses the
|
|
138
|
+
two inputs, anyone recomputes, and no state belonging to the recorder is involved at any point.
|
|
139
|
+
That is why it keeps working after the recorder is gone, and why it lives in the spine
|
|
140
|
+
rather than in the service.
|
|
141
|
+
|
|
142
|
+
The salt is why an address is not a search key. Without it, a short payload ("Termin abgesagt")
|
|
143
|
+
could be recovered from its hash by trying candidates; with it, the operator holds the one thing
|
|
144
|
+
that opens the commitment. Lose the salt and the record can never be opened again — the chain
|
|
145
|
+
still proves it existed unaltered at that position, and nothing proves what it said.
|
|
146
|
+
|
|
147
|
+
Raises :class:`MalformedSalt` if the salt is not exactly :data:`SALT_LEN` bytes. That check is
|
|
148
|
+
what makes the split unique and therefore what makes this a commitment to the PAIR rather than
|
|
149
|
+
to their concatenation — see the note on :data:`SALT_LEN`.
|
|
150
|
+
"""
|
|
151
|
+
if len(salt) != SALT_LEN:
|
|
152
|
+
raise MalformedSalt(
|
|
153
|
+
f"a salt is exactly {SALT_LEN} bytes; this one is {len(salt)}. Without a fixed length "
|
|
154
|
+
"the same address opens under many different splits, so a truncated payload would "
|
|
155
|
+
"verify as the whole one."
|
|
156
|
+
)
|
|
157
|
+
return content_address(salt + plaintext) == address
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def is_content_address(value: str) -> bool:
|
|
161
|
+
"""Is this string an address this module could have produced?
|
|
162
|
+
|
|
163
|
+
Three places asked `value.startswith("f")` instead, so `'f'` was a well-formed
|
|
164
|
+
address, and so were `'frei'`, `'f' + 'z' * 68`, a truncated digest and an
|
|
165
|
+
upper-case one. An archive reporting OK over a record whose payload_hash commits
|
|
166
|
+
to nothing leaves a court with nothing to recompute against, which is the whole of
|
|
167
|
+
the disclosure story when the archive holds no content.
|
|
168
|
+
|
|
169
|
+
It lives here for the reason `address_from_digest` gives: a second place that knows
|
|
170
|
+
the format is a place that one day answers differently. Deliberately NOT a validator
|
|
171
|
+
on `Record.payload_hash` — the spine and verifier suites build link-only records with
|
|
172
|
+
stub hashes, and a schema validator would turn an existing bad log into a parse
|
|
173
|
+
failure instead of a named finding.
|
|
174
|
+
"""
|
|
175
|
+
if not value.startswith(_MULTIBASE_BASE16_LOWER):
|
|
176
|
+
return False
|
|
177
|
+
body = value[1:]
|
|
178
|
+
# Upper-case hex is a DIFFERENT multibase ('F'), so a body that is not its own
|
|
179
|
+
# lower-case did not come from here even when it decodes.
|
|
180
|
+
if len(body) % 2 or body != body.lower():
|
|
181
|
+
return False
|
|
182
|
+
try:
|
|
183
|
+
code, length, digest = decode_multihash(bytes.fromhex(body))
|
|
184
|
+
except ValueError:
|
|
185
|
+
return False
|
|
186
|
+
return code == SHA2_256_CODE and length == SHA2_256_LENGTH and len(digest) == SHA2_256_LENGTH
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def decode_content_address(addr: str) -> bytes:
|
|
190
|
+
"""Inverse of :func:`content_address`; returns the raw multihash bytes."""
|
|
191
|
+
if not addr or addr[0] != _MULTIBASE_BASE16_LOWER:
|
|
192
|
+
raise ValueError("unsupported multibase prefix (expected base16 'f')")
|
|
193
|
+
return bytes.fromhex(addr[1:])
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def verify(data: bytes, mh: bytes) -> bool:
|
|
197
|
+
"""Return True iff ``mh`` is the multihash of ``data`` (constant-time)."""
|
|
198
|
+
try:
|
|
199
|
+
expected = multihash(data, algo="sha2-256")
|
|
200
|
+
except ValueError:
|
|
201
|
+
# FAIL CLOSED. Unreachable while sha2-256 is the only algorithm, but this module
|
|
202
|
+
# documents an algorithm migration as the point of the multihash envelope, and the
|
|
203
|
+
# migration is exactly when this branch starts to matter. `return True` here would
|
|
204
|
+
# make verify() answer True for ANY mh; the mutation lane found nothing pinning the
|
|
205
|
+
# direction, and `# pragma: no cover` meant coverage never would.
|
|
206
|
+
return False
|
|
207
|
+
return hmac.compare_digest(expected, mh)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""RFC 6962 Merkle tree: root, inclusion proofs, consistency proofs, verifiers.
|
|
3
|
+
|
|
4
|
+
Hashing is domain-separated exactly per RFC 6962 (leaf = ``SHA-256(0x00 || entry)``,
|
|
5
|
+
interior = ``SHA-256(0x01 || left || right)``) so proofs are portable and
|
|
6
|
+
interoperable with Certificate Transparency / Trillian-Tessera tooling — which is
|
|
7
|
+
what makes an export verifiable years later with no live service.
|
|
8
|
+
|
|
9
|
+
The prover (:func:`inclusion_proof`, :func:`consistency_proof`) follows the
|
|
10
|
+
recursive RFC 6962 definitions; the verifiers follow the iterative RFC 9162
|
|
11
|
+
algorithms. They are deliberately separate code paths so their agreement is
|
|
12
|
+
strong evidence of correctness.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from .hashing import sha256
|
|
18
|
+
|
|
19
|
+
LEAF_PREFIX = b"\x00"
|
|
20
|
+
NODE_PREFIX = b"\x01"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def leaf_hash(entry: bytes) -> bytes:
|
|
24
|
+
"""RFC 6962 leaf hash of a raw entry."""
|
|
25
|
+
return sha256(LEAF_PREFIX + entry)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def node_hash(left: bytes, right: bytes) -> bytes:
|
|
29
|
+
"""RFC 6962 interior node hash of two child hashes."""
|
|
30
|
+
return sha256(NODE_PREFIX + left + right)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _largest_power_of_two_less_than(n: int) -> int:
|
|
34
|
+
# Largest k that is a power of two with k < n (requires n >= 2).
|
|
35
|
+
k = 1
|
|
36
|
+
while (k << 1) < n:
|
|
37
|
+
k <<= 1
|
|
38
|
+
return k
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def merkle_root(entries: list[bytes]) -> bytes:
|
|
42
|
+
"""Return the RFC 6962 Merkle Tree Hash (MTH) over the ordered entries.
|
|
43
|
+
|
|
44
|
+
The hash of an empty list is the hash of the empty string.
|
|
45
|
+
"""
|
|
46
|
+
if not entries:
|
|
47
|
+
return sha256(b"")
|
|
48
|
+
if len(entries) == 1:
|
|
49
|
+
return leaf_hash(entries[0])
|
|
50
|
+
k = _largest_power_of_two_less_than(len(entries))
|
|
51
|
+
return node_hash(merkle_root(entries[:k]), merkle_root(entries[k:]))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def inclusion_proof(entries: list[bytes], index: int) -> list[bytes]:
|
|
55
|
+
"""Return the audit PATH for the entry at ``index`` (RFC 6962)."""
|
|
56
|
+
n = len(entries)
|
|
57
|
+
if not 0 <= index < n:
|
|
58
|
+
raise IndexError("leaf index out of range")
|
|
59
|
+
if n == 1:
|
|
60
|
+
return []
|
|
61
|
+
k = _largest_power_of_two_less_than(n)
|
|
62
|
+
if index < k:
|
|
63
|
+
return inclusion_proof(entries[:k], index) + [merkle_root(entries[k:])]
|
|
64
|
+
return inclusion_proof(entries[k:], index - k) + [merkle_root(entries[:k])]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _subproof(m: int, entries: list[bytes], at_root: bool) -> list[bytes]:
|
|
68
|
+
n = len(entries)
|
|
69
|
+
if m == n:
|
|
70
|
+
return [] if at_root else [merkle_root(entries)]
|
|
71
|
+
k = _largest_power_of_two_less_than(n)
|
|
72
|
+
if m <= k:
|
|
73
|
+
return _subproof(m, entries[:k], at_root) + [merkle_root(entries[k:])]
|
|
74
|
+
return _subproof(m - k, entries[k:], False) + [merkle_root(entries[:k])]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def consistency_proof(entries: list[bytes], first_size: int) -> list[bytes]:
|
|
78
|
+
"""Return the consistency PROOF between the tree of ``first_size`` and the full tree."""
|
|
79
|
+
n = len(entries)
|
|
80
|
+
if not 0 <= first_size <= n:
|
|
81
|
+
raise ValueError("first_size out of range")
|
|
82
|
+
if first_size == 0 or first_size == n:
|
|
83
|
+
return []
|
|
84
|
+
return _subproof(first_size, entries, True)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def verify_inclusion(
|
|
88
|
+
entry: bytes, index: int, tree_size: int, root: bytes, proof: list[bytes]
|
|
89
|
+
) -> bool:
|
|
90
|
+
"""Verify an inclusion proof for a raw ``entry`` (RFC 9162 algorithm)."""
|
|
91
|
+
# BOTH bounds, and a positive tree_size. A NEGATIVE index must be rejected: Python's saturating
|
|
92
|
+
# >> and & on negatives pin `fn` to a fixed parity, so every leaf would otherwise have an infinite
|
|
93
|
+
# family of negative aliases that verify against the same signed root — a forged, producer-
|
|
94
|
+
# controlled leaf_index reading as VALID. (The prover, inclusion_proof, always checked both
|
|
95
|
+
# bounds; only the verifier did not. Found in review.)
|
|
96
|
+
if not 0 <= index < tree_size:
|
|
97
|
+
return False
|
|
98
|
+
fn, sn = index, tree_size - 1
|
|
99
|
+
r = leaf_hash(entry)
|
|
100
|
+
for p in proof:
|
|
101
|
+
if sn == 0:
|
|
102
|
+
return False
|
|
103
|
+
if fn & 1 or fn == sn:
|
|
104
|
+
r = node_hash(p, r)
|
|
105
|
+
if not fn & 1:
|
|
106
|
+
while fn and not fn & 1:
|
|
107
|
+
fn >>= 1
|
|
108
|
+
sn >>= 1
|
|
109
|
+
else:
|
|
110
|
+
r = node_hash(r, p)
|
|
111
|
+
fn >>= 1
|
|
112
|
+
sn >>= 1
|
|
113
|
+
return sn == 0 and r == root
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def verify_consistency(
|
|
117
|
+
first_size: int,
|
|
118
|
+
second_size: int,
|
|
119
|
+
first_root: bytes,
|
|
120
|
+
second_root: bytes,
|
|
121
|
+
proof: list[bytes],
|
|
122
|
+
) -> bool:
|
|
123
|
+
"""Verify a consistency proof between two tree sizes (RFC 9162 algorithm)."""
|
|
124
|
+
# Reject negative sizes for the same reason as verify_inclusion: a negative operand would drive
|
|
125
|
+
# the shift/parity loop below into meaningless state rather than failing closed.
|
|
126
|
+
if first_size < 0 or second_size < 0 or first_size > second_size:
|
|
127
|
+
return False
|
|
128
|
+
if first_size == second_size:
|
|
129
|
+
return len(proof) == 0 and first_root == second_root
|
|
130
|
+
if first_size == 0:
|
|
131
|
+
return len(proof) == 0
|
|
132
|
+
fn, sn = first_size - 1, second_size - 1
|
|
133
|
+
while fn & 1:
|
|
134
|
+
fn >>= 1
|
|
135
|
+
sn >>= 1
|
|
136
|
+
it = iter(proof)
|
|
137
|
+
if fn == 0:
|
|
138
|
+
fr = sr = first_root
|
|
139
|
+
else:
|
|
140
|
+
try:
|
|
141
|
+
fr = sr = next(it)
|
|
142
|
+
except StopIteration:
|
|
143
|
+
return False
|
|
144
|
+
for c in it:
|
|
145
|
+
if sn == 0:
|
|
146
|
+
return False
|
|
147
|
+
if fn & 1 or fn == sn:
|
|
148
|
+
fr = node_hash(c, fr)
|
|
149
|
+
sr = node_hash(c, sr)
|
|
150
|
+
if not fn & 1:
|
|
151
|
+
while fn and not fn & 1:
|
|
152
|
+
fn >>= 1
|
|
153
|
+
sn >>= 1
|
|
154
|
+
else:
|
|
155
|
+
sr = node_hash(sr, c)
|
|
156
|
+
fn >>= 1
|
|
157
|
+
sn >>= 1
|
|
158
|
+
return sn == 0 and fr == first_root and sr == second_root
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class MerkleTree:
|
|
162
|
+
"""A small append-only RFC 6962 tree over in-memory entries."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, entries: list[bytes] | None = None) -> None:
|
|
165
|
+
self._entries: list[bytes] = list(entries) if entries else []
|
|
166
|
+
|
|
167
|
+
def add(self, entry: bytes) -> int:
|
|
168
|
+
"""Append a raw entry; return the new tree size."""
|
|
169
|
+
self._entries.append(entry)
|
|
170
|
+
return len(self._entries)
|
|
171
|
+
|
|
172
|
+
@property
|
|
173
|
+
def size(self) -> int:
|
|
174
|
+
return len(self._entries)
|
|
175
|
+
|
|
176
|
+
def entry(self, index: int) -> bytes:
|
|
177
|
+
return self._entries[index]
|
|
178
|
+
|
|
179
|
+
def root(self) -> bytes:
|
|
180
|
+
return merkle_root(self._entries)
|
|
181
|
+
|
|
182
|
+
def inclusion_proof(self, index: int) -> list[bytes]:
|
|
183
|
+
return inclusion_proof(self._entries, index)
|
|
184
|
+
|
|
185
|
+
def consistency_proof(self, first_size: int) -> list[bytes]:
|
|
186
|
+
return consistency_proof(self._entries, first_size)
|
tallwright_core/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The court-defensible record envelope, canonicalization, and hash-chain.
|
|
3
|
+
|
|
4
|
+
Realizes the 14-field record schema. Design rule:
|
|
5
|
+
the *record* holds hashes + metadata; the full payload (the context value, or the
|
|
6
|
+
action parameters) is sealed **off-record** and referenced by ``payload_hash``.
|
|
7
|
+
|
|
8
|
+
Canonicalization is RFC 8785 (JCS) so the record hash is reproducible by a court
|
|
9
|
+
expert years later. ``anchor_receipt`` and ``certification_pointer`` are attached
|
|
10
|
+
*after* sealing (anchoring is batched; certification is added later), so they are
|
|
11
|
+
EXCLUDED from the hashed and hash-chained content — otherwise a record could not
|
|
12
|
+
be anchored after its hash was computed.
|
|
13
|
+
|
|
14
|
+
Framing discipline (non-negotiable): we record the context received and the action
|
|
15
|
+
triggered — never the agent's reasoning or intent. Integrity is from the moment of
|
|
16
|
+
capture, not the truth of the inputs.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
25
|
+
|
|
26
|
+
from .canonical import canonicalize
|
|
27
|
+
from .hashing import content_address, multihash
|
|
28
|
+
|
|
29
|
+
SCHEMA_VERSION = "0.1.0"
|
|
30
|
+
HASH_ALGORITHM = "sha2-256"
|
|
31
|
+
CANONICALIZATION_METHOD = "RFC8785-JCS"
|
|
32
|
+
|
|
33
|
+
# Attached post-sealing -> never part of the record hash / chain.
|
|
34
|
+
_UNHASHED_FIELDS = ("anchor_receipt", "certification_pointer")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EventType(str, Enum):
|
|
38
|
+
"""We seal what the agent received and what it triggered — nothing else."""
|
|
39
|
+
|
|
40
|
+
CONTEXT_RECEIVED = "CONTEXT_RECEIVED"
|
|
41
|
+
ACTION_TRIGGERED = "ACTION_TRIGGERED"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
#: How a capture gap announces itself in ``GapManifest.notes``.
|
|
45
|
+
#:
|
|
46
|
+
#: Defined HERE because two packages have to agree on it and neither owns the other: the capture
|
|
47
|
+
#: middleware writes it when a capture is dropped, and the alarm reads it to tell the operator.
|
|
48
|
+
#: Two spellings of one marker is a gap that is recorded and never reported — which is the same as
|
|
49
|
+
#: not recording it, on the day it matters.
|
|
50
|
+
RUPTURE_NOTE_PREFIX = "rupture: "
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class GapManifest(BaseModel):
|
|
54
|
+
"""Declares which capture tiers were active, scoping any completeness claim.
|
|
55
|
+
|
|
56
|
+
Every tier defaults to FALSE, including the strongest one. A record built without saying how it
|
|
57
|
+
was captured must not claim we watched it happen — and until 2026-08-25 it did, because
|
|
58
|
+
``tier1_runtime_hooks`` defaulted to True and ``Record.gap_manifest`` has a default factory.
|
|
59
|
+
Both archive paths of the product's closed service build records without a manifest, so every
|
|
60
|
+
archived record asserted boundary capture; on the hashes-only path, where the operator sends an
|
|
61
|
+
address and we see nothing by construction, that assertion was flatly false.
|
|
62
|
+
|
|
63
|
+
The adapter that genuinely installs runtime hooks says so explicitly and still does. Everything
|
|
64
|
+
else now under-claims, which is the only direction this may ever fail in.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(extra="forbid")
|
|
68
|
+
|
|
69
|
+
tier1_runtime_hooks: bool = False
|
|
70
|
+
tier2_transport_bytes: bool = False
|
|
71
|
+
tier3_mcp_proxy: bool = False
|
|
72
|
+
notes: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AnchorReceipt(BaseModel):
|
|
76
|
+
"""External-anchor evidence (the moat field); attached after batched anchoring."""
|
|
77
|
+
|
|
78
|
+
model_config = ConfigDict(extra="forbid")
|
|
79
|
+
|
|
80
|
+
merkle_root: str
|
|
81
|
+
tree_size: int
|
|
82
|
+
anchored_at: str
|
|
83
|
+
timestamp_ref: str | None = None # always set; honest about the token
|
|
84
|
+
timestamp_algorithm: str | None = None # e.g. "local-ed25519-ts" (non-qualified) vs "rfc3161-cms"
|
|
85
|
+
qualified_timestamp_ref: str | None = None # ONLY set for a genuinely qualified token
|
|
86
|
+
witness_checkpoint_ref: str | None = None
|
|
87
|
+
public_anchor_ref: str | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Record(BaseModel):
|
|
91
|
+
"""A single sealed context/action record.
|
|
92
|
+
|
|
93
|
+
The field numbers in comments number the record's fourteen fields.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
model_config = ConfigDict(extra="forbid")
|
|
97
|
+
|
|
98
|
+
# 13 — re-verifiability + reproducible hashing
|
|
99
|
+
schema_version: str = SCHEMA_VERSION
|
|
100
|
+
hash_algorithm: str = HASH_ALGORITHM
|
|
101
|
+
canonicalization_method: str = CANONICALIZATION_METHOD
|
|
102
|
+
|
|
103
|
+
# 1 record id, 2 chain link, 3 content-address of the sealed payload
|
|
104
|
+
record_id: str
|
|
105
|
+
prev_record_hash: str | None = None
|
|
106
|
+
payload_hash: str
|
|
107
|
+
|
|
108
|
+
# 4 trusted time (RFC 3339 string; upgrade to a qualified TS where weight matters)
|
|
109
|
+
timestamp: str
|
|
110
|
+
|
|
111
|
+
# 5 runtime + version, 6 accountable operator
|
|
112
|
+
agent_system_id: str
|
|
113
|
+
agent_version: str
|
|
114
|
+
deployer_operator_id: str
|
|
115
|
+
|
|
116
|
+
# 7 event type; 8/9 the payload itself is off-record (payload_hash), action_type
|
|
117
|
+
# is the small inline metadata for ACTION_TRIGGERED
|
|
118
|
+
event_type: EventType
|
|
119
|
+
action_type: str | None = None
|
|
120
|
+
|
|
121
|
+
# 10 accountable authority
|
|
122
|
+
actor_authority: str | None = None
|
|
123
|
+
|
|
124
|
+
# 11 retention + legal hold
|
|
125
|
+
retention_class: str
|
|
126
|
+
legal_hold: bool = False
|
|
127
|
+
|
|
128
|
+
# capture-completeness scope
|
|
129
|
+
gap_manifest: GapManifest = Field(default_factory=GapManifest)
|
|
130
|
+
|
|
131
|
+
# 12 anchor (post-seal) + 14 certification (post-seal) -> EXCLUDED from the hash
|
|
132
|
+
anchor_receipt: AnchorReceipt | None = None
|
|
133
|
+
certification_pointer: str | None = None
|
|
134
|
+
|
|
135
|
+
@model_validator(mode="after")
|
|
136
|
+
def _check_event_fields(self) -> Record:
|
|
137
|
+
if self.event_type is EventType.ACTION_TRIGGERED:
|
|
138
|
+
if not self.action_type:
|
|
139
|
+
raise ValueError("ACTION_TRIGGERED requires action_type")
|
|
140
|
+
elif self.action_type is not None:
|
|
141
|
+
raise ValueError("action_type must be unset for CONTEXT_RECEIVED")
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def hashable_dict(self) -> dict[str, Any]:
|
|
145
|
+
"""The JSON-able record content that the hash/chain cover (post-seal fields removed)."""
|
|
146
|
+
data: dict[str, Any] = self.model_dump(mode="json")
|
|
147
|
+
for field in _UNHASHED_FIELDS:
|
|
148
|
+
data.pop(field, None)
|
|
149
|
+
return data
|
|
150
|
+
|
|
151
|
+
def canonical_bytes(self) -> bytes:
|
|
152
|
+
"""Deterministic RFC 8785 (JCS) serialization of the hashable content."""
|
|
153
|
+
return canonicalize(self.hashable_dict())
|
|
154
|
+
|
|
155
|
+
def record_hash(self) -> bytes:
|
|
156
|
+
"""The raw multihash over the canonical bytes."""
|
|
157
|
+
return multihash(self.canonical_bytes())
|
|
158
|
+
|
|
159
|
+
def record_hash_address(self) -> str:
|
|
160
|
+
"""The content address (multibase string) of this record's hash."""
|
|
161
|
+
return content_address(self.canonical_bytes())
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def link(prev: Record, **fields: Any) -> Record:
|
|
165
|
+
"""Build the next record, chaining ``prev_record_hash`` to ``prev``."""
|
|
166
|
+
return Record(prev_record_hash=prev.record_hash_address(), **fields)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def verify_chain(records: list[Record]) -> bool:
|
|
170
|
+
"""Return True iff each record's ``prev_record_hash`` matches the prior record."""
|
|
171
|
+
prev_addr: str | None = None
|
|
172
|
+
for rec in records:
|
|
173
|
+
if rec.prev_record_hash != prev_addr:
|
|
174
|
+
return False
|
|
175
|
+
prev_addr = rec.record_hash_address()
|
|
176
|
+
return True
|
tallwright_core/write.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Replacing a document's contents in one step, or not at all.
|
|
3
|
+
|
|
4
|
+
The mirror of :mod:`tallwright_core.document`: that one refuses to READ a file that says two
|
|
5
|
+
things, this one refuses to LEAVE a file that is half of one.
|
|
6
|
+
|
|
7
|
+
``Path.write_text`` opens with ``"w"``: it truncates first and writes second. A crash or a full
|
|
8
|
+
disk between those leaves a file that is neither the old contents nor the new " and the files this
|
|
9
|
+
matters for are the ones that hold everything. `fleet.json` carries every tenant an operator has,
|
|
10
|
+
every API-key hash and the witness identity they all anchor to; the operator config carries the
|
|
11
|
+
checkpoint key's public half and the origin. Losing one of those to a torn write costs a customer
|
|
12
|
+
list, not a cache.
|
|
13
|
+
|
|
14
|
+
**This does not claim durability.** Without an ``fsync`` a crash can lose the write entirely. What
|
|
15
|
+
it cannot do any more is lose it halfway, and a lost write leaves the OLD file " which is a thing
|
|
16
|
+
somebody can still read " where a torn one leaves nothing.
|
|
17
|
+
|
|
18
|
+
The scratch lives in the SAME directory, because ``os.replace`` is only atomic within one
|
|
19
|
+
filesystem. Where the target directory is sealed append-only, the scratch has to live outside it
|
|
20
|
+
instead and the rename goes in " that case is not this one and
|
|
21
|
+
``Archive._stage_anchor_file`` handles it separately.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
__all__ = ["write_document"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def write_document(path: Path, text: str) -> None:
|
|
33
|
+
"""Write ``text`` to ``path`` so a reader sees either the old contents or all of the new."""
|
|
34
|
+
scratch = path.with_name(f".{path.name}.part")
|
|
35
|
+
scratch.write_text(text, encoding="utf-8")
|
|
36
|
+
os.replace(scratch, path)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tallwright-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Tallwright - shared record schema, content-addressing, and RFC 6962 Merkle primitives
|
|
5
|
+
Author: Tallwright
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
License-File: NOTICE
|
|
9
|
+
Keywords: ai-agents,audit,compliance,forensics,merkle,tamper-evident
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Legal Industry
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: pydantic<3,>=2
|
|
19
|
+
Requires-Dist: rfc8785<1,>=0.1
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# tallwright-core
|
|
23
|
+
|
|
24
|
+
The dependency-light foundation every other Tallwright package builds on. No
|
|
25
|
+
networked or external-service code lives here.
|
|
26
|
+
|
|
27
|
+
- **`hashing.py`** — SHA-256 self-describing multihash + multibase content-addressing
|
|
28
|
+
- **`merkle.py`** — RFC 6962 Merkle tree: root, inclusion + consistency proofs, and
|
|
29
|
+
independent (RFC 9162) verifiers
|
|
30
|
+
- **`schema.py`** — the 14-field record envelope,
|
|
31
|
+
RFC 8785 (JCS) canonicalization, record hash, and hash-chain (`link` / `verify_chain`).
|
|
32
|
+
Post-seal fields (`anchor_receipt`, `certification_pointer`) are excluded from the hash.
|
|
33
|
+
|
|
34
|
+
Status: **implemented + tested** (the first approved production code). Anchoring, signing,
|
|
35
|
+
storage, and capture live in later packages.
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
Copyright 2026 David Jöbstl. Licensed under the Apache License, Version 2.0: the package carries the license
|
|
40
|
+
text and the NOTICE. The exhibit
|
|
41
|
+
format's specification and its test vectors are under the same license; the rest of Tallwright is
|
|
42
|
+
proprietary.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
tallwright_core/__init__.py,sha256=veR1oRr5cgnXJZZX4IV7ZyhLQeG4BzG3m-nNMi64J8Y,2002
|
|
2
|
+
tallwright_core/canonical.py,sha256=Wp_A24lWyaPnU-de9rhmS6dG1p_HE64emAD5FOLDE8E,4374
|
|
3
|
+
tallwright_core/document.py,sha256=6gUItl8iaQr-OXGr3WS7gcdcnAz6jKov89-KMzOVUhg,5197
|
|
4
|
+
tallwright_core/hashing.py,sha256=1F6SX31sCNCaEV23UpN6Dv3Z8uYdihdSX9qZ-peWDcU,9296
|
|
5
|
+
tallwright_core/merkle.py,sha256=xSUCJ2kMkRCF2G2irWxspFalh-NpsjPE9x2kWeIeses,6328
|
|
6
|
+
tallwright_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
tallwright_core/schema.py,sha256=WT5f7oMDpZMjpNOdZY34BJaWk_NdND2tKjnbeNlNn2Q,6788
|
|
8
|
+
tallwright_core/write.py,sha256=DVD0JaWyvTez8NFBPt2LqFM4MpUMjXJmPdNxbkZvbz4,1710
|
|
9
|
+
tallwright_core-0.1.0.dist-info/METADATA,sha256=f8_2l70RJv-ntWZVwaiZzNBArZZHZ_Tjl_78jZHB7FM,1749
|
|
10
|
+
tallwright_core-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
11
|
+
tallwright_core-0.1.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
|
|
12
|
+
tallwright_core-0.1.0.dist-info/licenses/NOTICE,sha256=V9DbIbqPvS6ooaxdVebPZgfjXOgZcZcz7hLYGUeRMc8,262
|
|
13
|
+
tallwright_core-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|