orphograph 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.
- orphograph/__init__.py +175 -0
- orphograph/__main__.py +5 -0
- orphograph/_cli.py +129 -0
- orphograph/_client.py +148 -0
- orphograph/_merkle.py +493 -0
- orphograph/py.typed +0 -0
- orphograph-0.1.0.dist-info/METADATA +161 -0
- orphograph-0.1.0.dist-info/RECORD +12 -0
- orphograph-0.1.0.dist-info/WHEEL +5 -0
- orphograph-0.1.0.dist-info/entry_points.txt +2 -0
- orphograph-0.1.0.dist-info/licenses/LICENSE +21 -0
- orphograph-0.1.0.dist-info/top_level.txt +1 -0
orphograph/__init__.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""orphograph — Python SDK for anchoring folders to Bitcoin.
|
|
2
|
+
|
|
3
|
+
The SDK constructs an RFC 6962-style Merkle tree from a local folder, then
|
|
4
|
+
submits only the manifest (paths, per-file SHA-256 digests, leaf hashes,
|
|
5
|
+
and a 32-byte root) to the Orphograph hosted service. File bodies do not
|
|
6
|
+
cross the network at any point in this module.
|
|
7
|
+
|
|
8
|
+
Public API:
|
|
9
|
+
|
|
10
|
+
anchor_folder(folder_path, server_url=..., api_key=..., client_label=..., exclude=...)
|
|
11
|
+
verify_folder(folder_path, receipt_id, server_url=...)
|
|
12
|
+
inclusion_proof(receipt_id, path, server_url=...)
|
|
13
|
+
verify_inclusion(file_path, rel_path, proof, root_hex)
|
|
14
|
+
|
|
15
|
+
Algorithm tag: ``orphograph-merkle-v1-rfc6962``.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Optional, Sequence
|
|
22
|
+
|
|
23
|
+
from . import _client
|
|
24
|
+
from ._client import OrphographError
|
|
25
|
+
from ._merkle import ALGORITHM, MerkleTree
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ALGORITHM",
|
|
29
|
+
"OrphographError",
|
|
30
|
+
"anchor_folder",
|
|
31
|
+
"verify_folder",
|
|
32
|
+
"inclusion_proof",
|
|
33
|
+
"verify_inclusion",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.0"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def anchor_folder(
|
|
40
|
+
folder_path: str,
|
|
41
|
+
*,
|
|
42
|
+
server_url: str = _client.DEFAULT_SERVER_URL,
|
|
43
|
+
api_key: Optional[str] = None,
|
|
44
|
+
client_label: Optional[str] = None,
|
|
45
|
+
exclude: Optional[Sequence[str]] = None,
|
|
46
|
+
) -> dict:
|
|
47
|
+
"""Anchor a folder to Bitcoin via the Orphograph hosted service.
|
|
48
|
+
|
|
49
|
+
The Merkle tree is constructed locally; only the resulting manifest
|
|
50
|
+
(paths + SHA-256 digests + leaf hashes + root) is transmitted to the
|
|
51
|
+
server. File contents stay on disk.
|
|
52
|
+
|
|
53
|
+
Returns a dict with keys: ``receipt_id``, ``root_hex``, ``leaf_count``,
|
|
54
|
+
``calendars_ok``, ``calendars_total``.
|
|
55
|
+
"""
|
|
56
|
+
root = Path(folder_path)
|
|
57
|
+
if not root.is_dir():
|
|
58
|
+
raise ValueError(f"not a directory: {folder_path}")
|
|
59
|
+
excl_list = list(exclude) if exclude is not None else None
|
|
60
|
+
tree = MerkleTree.from_folder(root, exclude=excl_list)
|
|
61
|
+
manifest = tree.manifest()
|
|
62
|
+
response = _client.post_anchor_folder(
|
|
63
|
+
manifest,
|
|
64
|
+
server_url=server_url,
|
|
65
|
+
api_key=api_key,
|
|
66
|
+
client_label=client_label,
|
|
67
|
+
)
|
|
68
|
+
return {
|
|
69
|
+
"receipt_id": response.get("receipt_id"),
|
|
70
|
+
"root_hex": response.get("root_hex", tree.root_hex()),
|
|
71
|
+
"leaf_count": response.get("leaf_count", len(manifest["leaves"])),
|
|
72
|
+
"calendars_ok": response.get("calendars_ok"),
|
|
73
|
+
"calendars_total": response.get("calendars_total"),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def verify_folder(
|
|
78
|
+
folder_path: str,
|
|
79
|
+
receipt_id: str,
|
|
80
|
+
*,
|
|
81
|
+
server_url: str = _client.DEFAULT_SERVER_URL,
|
|
82
|
+
api_key: Optional[str] = None,
|
|
83
|
+
exclude: Optional[list] = None,
|
|
84
|
+
) -> bool:
|
|
85
|
+
"""Verify a local folder against a previously anchored receipt.
|
|
86
|
+
|
|
87
|
+
Rebuilds the Merkle root locally from the folder on disk and compares
|
|
88
|
+
it byte-for-byte to the root recorded in the receipt's manifest. The
|
|
89
|
+
server is consulted only to fetch the manifest; file contents are not
|
|
90
|
+
transmitted.
|
|
91
|
+
"""
|
|
92
|
+
root = Path(folder_path)
|
|
93
|
+
if not root.is_dir():
|
|
94
|
+
raise ValueError(f"not a directory: {folder_path}")
|
|
95
|
+
response = _client.get_verify_folder(receipt_id, server_url=server_url, api_key=api_key)
|
|
96
|
+
manifest = response.get("manifest") or {}
|
|
97
|
+
server_root = manifest.get("root_hex")
|
|
98
|
+
if not server_root:
|
|
99
|
+
return False
|
|
100
|
+
# The manifest is authoritative for its own scope.
|
|
101
|
+
#
|
|
102
|
+
# Previously this mirrored whatever the CALLER passed, which only moved the
|
|
103
|
+
# problem: a folder anchored with custom excludes still could not verify
|
|
104
|
+
# unless the verifier independently remembered the exact list used at
|
|
105
|
+
# capture, months earlier, possibly on another machine. That is not a
|
|
106
|
+
# property evidence should have.
|
|
107
|
+
#
|
|
108
|
+
# Manifests now record the effective patterns (see merkle.build_scope), so
|
|
109
|
+
# they are read from the manifest when present. The caller's `exclude` is
|
|
110
|
+
# only a fallback for manifests issued before scope existed.
|
|
111
|
+
effective_exclude = exclude
|
|
112
|
+
scope = manifest.get("scope")
|
|
113
|
+
if isinstance(scope, dict) and isinstance(scope.get("exclude"), list):
|
|
114
|
+
effective_exclude = list(scope["exclude"])
|
|
115
|
+
tree = MerkleTree.from_folder(root, exclude=effective_exclude)
|
|
116
|
+
return tree.root_hex() == server_root
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def inclusion_proof(
|
|
120
|
+
receipt_id: str,
|
|
121
|
+
path: str,
|
|
122
|
+
*,
|
|
123
|
+
server_url: str = _client.DEFAULT_SERVER_URL,
|
|
124
|
+
api_key: Optional[str] = None,
|
|
125
|
+
) -> dict:
|
|
126
|
+
"""Fetch an inclusion proof for one POSIX-relative path in a folder receipt.
|
|
127
|
+
|
|
128
|
+
Returns the server's JSON payload: ``receipt_id``, ``root_hex``,
|
|
129
|
+
``path``, ``file_sha256_hex``, ``proof`` (list of ``[direction, hex]``).
|
|
130
|
+
"""
|
|
131
|
+
return _client.get_inclusion_proof(
|
|
132
|
+
receipt_id, path, server_url=server_url, api_key=api_key
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def verify_inclusion(
|
|
137
|
+
file_path: str,
|
|
138
|
+
rel_path: str,
|
|
139
|
+
proof: Sequence,
|
|
140
|
+
root_hex: str,
|
|
141
|
+
) -> bool:
|
|
142
|
+
"""Verify locally that a file was included in an anchored folder.
|
|
143
|
+
|
|
144
|
+
Reads the file from disk, computes its SHA-256, and walks the proof
|
|
145
|
+
upward against the supplied root. No network call is made.
|
|
146
|
+
|
|
147
|
+
Raises ``FileNotFoundError`` when ``file_path`` does not exist (or is
|
|
148
|
+
not a regular file). A missing local file is an I/O precondition
|
|
149
|
+
failure, not a "not included" verdict — for a notary, a distinguishable
|
|
150
|
+
error beats a silent ``False`` (audit D7; matches the Node SDK, whose
|
|
151
|
+
``verifyInclusion`` rejects with the filesystem error). A malformed
|
|
152
|
+
proof or root still returns ``False`` per VERIFIER_SPEC §4.1.
|
|
153
|
+
"""
|
|
154
|
+
p = Path(file_path)
|
|
155
|
+
if not p.is_file():
|
|
156
|
+
raise FileNotFoundError(f"no such file: {file_path}")
|
|
157
|
+
h = hashlib.sha256()
|
|
158
|
+
with p.open("rb") as f:
|
|
159
|
+
while True:
|
|
160
|
+
chunk = f.read(1024 * 1024)
|
|
161
|
+
if not chunk:
|
|
162
|
+
break
|
|
163
|
+
h.update(chunk)
|
|
164
|
+
file_hash = h.digest()
|
|
165
|
+
try:
|
|
166
|
+
root = bytes.fromhex(root_hex)
|
|
167
|
+
except (ValueError, TypeError):
|
|
168
|
+
return False
|
|
169
|
+
# Coerce proof entries (which may arrive as lists from JSON) to tuples.
|
|
170
|
+
normalised: list = []
|
|
171
|
+
for step in proof:
|
|
172
|
+
if not isinstance(step, (list, tuple)) or len(step) != 2:
|
|
173
|
+
return False
|
|
174
|
+
normalised.append((step[0], step[1]))
|
|
175
|
+
return MerkleTree.verify_inclusion(file_hash, rel_path, normalised, root)
|
orphograph/__main__.py
ADDED
orphograph/_cli.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""orphograph._cli — argparse-based command-line interface.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
|
|
5
|
+
anchor <folder> Anchor a folder; prints one line of JSON.
|
|
6
|
+
verify <folder> <receipt_id> Verify a folder against a receipt.
|
|
7
|
+
inclusion-proof <rid> <path> Fetch an inclusion proof; prints JSON.
|
|
8
|
+
|
|
9
|
+
Both ``anchor`` and ``verify`` accept repeatable ``--exclude GLOB`` flags.
|
|
10
|
+
A folder anchored with custom excludes can only re-derive the same Merkle
|
|
11
|
+
root when verified with the SAME excludes (AUDIT_VERIFIER_DRIFT D2). Since
|
|
12
|
+
Wedge 01 the manifest records them in its ``scope`` block and ``verify``
|
|
13
|
+
reads them from there (the manifest is authoritative — VERIFIER_SPEC §4.2);
|
|
14
|
+
the ``--exclude`` flag on ``verify`` only applies to manifests that carry no
|
|
15
|
+
scope block (issued before scope existed).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from typing import List, Optional
|
|
24
|
+
|
|
25
|
+
from . import anchor_folder, inclusion_proof, verify_folder
|
|
26
|
+
from ._client import DEFAULT_SERVER_URL, OrphographError
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _env_server() -> str:
|
|
30
|
+
return os.environ.get("ORPHO_SERVER_URL", DEFAULT_SERVER_URL)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _env_api_key() -> Optional[str]:
|
|
34
|
+
val = os.environ.get("ORPHO_API_KEY", "").strip()
|
|
35
|
+
return val or None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
39
|
+
parser = argparse.ArgumentParser(
|
|
40
|
+
prog="orphograph",
|
|
41
|
+
description="Anchor folders to Bitcoin via the Orphograph service.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--server-url",
|
|
45
|
+
default=None,
|
|
46
|
+
help="Base URL of the Orphograph service (default: ORPHO_SERVER_URL or https://orphograph.com).",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--api-key",
|
|
50
|
+
default=None,
|
|
51
|
+
help="Optional API key (default: ORPHO_API_KEY).",
|
|
52
|
+
)
|
|
53
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
54
|
+
|
|
55
|
+
exclude_help = (
|
|
56
|
+
"Glob pattern to exclude (repeatable). Supplying any --exclude "
|
|
57
|
+
"REPLACES the default deny-list rather than extending it. "
|
|
58
|
+
"On verify: applies only to manifests without a scope block — a "
|
|
59
|
+
"manifest's recorded scope.exclude is authoritative (VERIFIER_SPEC §4.2)."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
p_anchor = sub.add_parser("anchor", help="Anchor a folder.")
|
|
63
|
+
p_anchor.add_argument("folder", help="Local folder to anchor.")
|
|
64
|
+
p_anchor.add_argument("--label", default=None, help="Optional short client label.")
|
|
65
|
+
p_anchor.add_argument(
|
|
66
|
+
"--exclude", action="append", default=None, metavar="GLOB", help=exclude_help
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
p_verify = sub.add_parser("verify", help="Verify a folder against a receipt.")
|
|
70
|
+
p_verify.add_argument("folder", help="Local folder to verify.")
|
|
71
|
+
p_verify.add_argument("receipt_id", help="Receipt id returned at anchor time.")
|
|
72
|
+
p_verify.add_argument(
|
|
73
|
+
"--exclude", action="append", default=None, metavar="GLOB", help=exclude_help
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
p_proof = sub.add_parser("inclusion-proof", help="Fetch an inclusion proof.")
|
|
77
|
+
p_proof.add_argument("receipt_id", help="Folder receipt id.")
|
|
78
|
+
p_proof.add_argument("path", help="POSIX relative path inside the folder.")
|
|
79
|
+
|
|
80
|
+
return parser
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
84
|
+
parser = _build_parser()
|
|
85
|
+
args = parser.parse_args(argv)
|
|
86
|
+
server_url = args.server_url or _env_server()
|
|
87
|
+
api_key = args.api_key if args.api_key is not None else _env_api_key()
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
if args.command == "anchor":
|
|
91
|
+
result = anchor_folder(
|
|
92
|
+
args.folder,
|
|
93
|
+
server_url=server_url,
|
|
94
|
+
api_key=api_key,
|
|
95
|
+
client_label=args.label,
|
|
96
|
+
exclude=args.exclude,
|
|
97
|
+
)
|
|
98
|
+
sys.stdout.write(json.dumps(result) + "\n")
|
|
99
|
+
return 0
|
|
100
|
+
if args.command == "verify":
|
|
101
|
+
ok = verify_folder(
|
|
102
|
+
args.folder,
|
|
103
|
+
args.receipt_id,
|
|
104
|
+
server_url=server_url,
|
|
105
|
+
api_key=api_key,
|
|
106
|
+
exclude=args.exclude,
|
|
107
|
+
)
|
|
108
|
+
sys.stdout.write(json.dumps({"match": bool(ok)}) + "\n")
|
|
109
|
+
return 0 if ok else 1
|
|
110
|
+
if args.command == "inclusion-proof":
|
|
111
|
+
proof = inclusion_proof(
|
|
112
|
+
args.receipt_id,
|
|
113
|
+
args.path,
|
|
114
|
+
server_url=server_url,
|
|
115
|
+
api_key=api_key,
|
|
116
|
+
)
|
|
117
|
+
sys.stdout.write(json.dumps(proof) + "\n")
|
|
118
|
+
return 0
|
|
119
|
+
except OrphographError as e:
|
|
120
|
+
sys.stderr.write(json.dumps({"error": e.message, "status": e.status}) + "\n")
|
|
121
|
+
return 2
|
|
122
|
+
except (OSError, ValueError) as e:
|
|
123
|
+
sys.stderr.write(json.dumps({"error": str(e)}) + "\n")
|
|
124
|
+
return 2
|
|
125
|
+
return 2
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
raise SystemExit(main())
|
orphograph/_client.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""orphograph._client — HTTP transport to the Orphograph REST API.
|
|
2
|
+
|
|
3
|
+
This client never reads file contents. It builds a Merkle manifest from
|
|
4
|
+
on-disk SHA-256 digests, then transmits only the manifest and root hash.
|
|
5
|
+
File bodies remain on the local machine at all times. The wire payload is
|
|
6
|
+
the manifest JSON described in ``orphograph-merkle-v1-rfc6962`` — relative
|
|
7
|
+
POSIX paths, per-file SHA-256 digests, leaf hashes, and a 32-byte root.
|
|
8
|
+
|
|
9
|
+
The module uses ``urllib.request`` and ``json`` from the standard library
|
|
10
|
+
only. No third-party HTTP dependency is introduced on the runtime path.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.parse
|
|
17
|
+
import urllib.request
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
DEFAULT_SERVER_URL = "https://orphograph.com"
|
|
21
|
+
DEFAULT_TIMEOUT = 60.0
|
|
22
|
+
# Honest, self-identifying User-Agent. NEVER a browser-spoofing string.
|
|
23
|
+
#
|
|
24
|
+
# The comment that used to sit here said the service "sits behind a CDN whose
|
|
25
|
+
# default-deny posture blocks scripted clients identifying themselves as such"
|
|
26
|
+
# and that "only the leading Mozilla/5.0 appeases the gateway". It was tested
|
|
27
|
+
# on 2026-08-20 against https://orphograph.com/api/health:
|
|
28
|
+
#
|
|
29
|
+
# Python-urllib/3.11 ............................ 403
|
|
30
|
+
# no User-Agent header at all ................... 200
|
|
31
|
+
# curl/8.7.1 .................................... 200
|
|
32
|
+
# orphograph-python-sdk/0.1 (+https://…) ........ 200
|
|
33
|
+
#
|
|
34
|
+
# The premise was right and the conclusion was wrong: the gateway blocks one
|
|
35
|
+
# literal token, not scripted clients as a class. An SDK that impersonates
|
|
36
|
+
# Safari is also lying to its own server logs about what its traffic is, and
|
|
37
|
+
# this SDK ships to third parties, so the lie propagated to every user of it.
|
|
38
|
+
# All that is required is that a UA is SET, so urllib never falls back to its
|
|
39
|
+
# default.
|
|
40
|
+
USER_AGENT = "orphograph-python-sdk/0.1 (+https://orphograph.com)"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OrphographError(RuntimeError):
|
|
44
|
+
"""Raised when the hosted service returns a non-2xx response."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, status: int, message: str, payload: Optional[dict] = None):
|
|
47
|
+
super().__init__(f"HTTP {status}: {message}")
|
|
48
|
+
self.status = status
|
|
49
|
+
self.message = message
|
|
50
|
+
self.payload = payload or {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _normalise_base(server_url: str) -> str:
|
|
54
|
+
if not server_url:
|
|
55
|
+
raise ValueError("server_url is required")
|
|
56
|
+
return server_url.rstrip("/")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_headers(api_key: Optional[str], content_type: Optional[str]) -> dict:
|
|
60
|
+
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
61
|
+
if content_type is not None:
|
|
62
|
+
headers["Content-Type"] = content_type
|
|
63
|
+
if api_key:
|
|
64
|
+
headers["X-Orpho-Api-Key"] = api_key.strip()
|
|
65
|
+
return headers
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _request(
|
|
69
|
+
method: str,
|
|
70
|
+
url: str,
|
|
71
|
+
*,
|
|
72
|
+
body: Optional[bytes] = None,
|
|
73
|
+
headers: Optional[dict] = None,
|
|
74
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
75
|
+
) -> dict:
|
|
76
|
+
req = urllib.request.Request(url=url, data=body, method=method, headers=headers or {})
|
|
77
|
+
try:
|
|
78
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
79
|
+
raw = resp.read()
|
|
80
|
+
status = resp.getcode()
|
|
81
|
+
except urllib.error.HTTPError as e:
|
|
82
|
+
raw = e.read() or b""
|
|
83
|
+
status = e.code
|
|
84
|
+
payload: dict = {}
|
|
85
|
+
try:
|
|
86
|
+
payload = json.loads(raw.decode("utf-8")) if raw else {}
|
|
87
|
+
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
88
|
+
payload = {"error": raw[:200].decode("utf-8", "replace")}
|
|
89
|
+
raise OrphographError(status, payload.get("error") or e.reason or "request failed", payload)
|
|
90
|
+
if not (200 <= status < 300):
|
|
91
|
+
raise OrphographError(status, "unexpected status")
|
|
92
|
+
if not raw:
|
|
93
|
+
return {}
|
|
94
|
+
try:
|
|
95
|
+
return json.loads(raw.decode("utf-8"))
|
|
96
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as e:
|
|
97
|
+
raise OrphographError(status, f"invalid JSON response: {e}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def post_anchor_folder(
|
|
101
|
+
manifest: dict,
|
|
102
|
+
*,
|
|
103
|
+
server_url: str = DEFAULT_SERVER_URL,
|
|
104
|
+
api_key: Optional[str] = None,
|
|
105
|
+
client_label: Optional[str] = None,
|
|
106
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
107
|
+
) -> dict:
|
|
108
|
+
"""POST a manifest to /api/anchor_folder.
|
|
109
|
+
|
|
110
|
+
Only the manifest (paths + per-file SHA-256 + leaf hashes + root) is
|
|
111
|
+
transmitted. File contents are not part of the payload.
|
|
112
|
+
"""
|
|
113
|
+
base = _normalise_base(server_url)
|
|
114
|
+
payload: dict[str, Any] = {"manifest": manifest}
|
|
115
|
+
if client_label is not None:
|
|
116
|
+
payload["client_label"] = str(client_label)[:200]
|
|
117
|
+
body = json.dumps(payload).encode("utf-8")
|
|
118
|
+
headers = _build_headers(api_key, "application/json")
|
|
119
|
+
return _request("POST", base + "/api/anchor_folder", body=body, headers=headers, timeout=timeout)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_verify_folder(
|
|
123
|
+
receipt_id: str,
|
|
124
|
+
*,
|
|
125
|
+
server_url: str = DEFAULT_SERVER_URL,
|
|
126
|
+
api_key: Optional[str] = None,
|
|
127
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
128
|
+
) -> dict:
|
|
129
|
+
"""GET /api/verify_folder/<receipt_id>."""
|
|
130
|
+
base = _normalise_base(server_url)
|
|
131
|
+
rid = urllib.parse.quote(receipt_id, safe="")
|
|
132
|
+
headers = _build_headers(api_key, None)
|
|
133
|
+
return _request("GET", f"{base}/api/verify_folder/{rid}", headers=headers, timeout=timeout)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_inclusion_proof(
|
|
137
|
+
receipt_id: str,
|
|
138
|
+
rel_path: str,
|
|
139
|
+
*,
|
|
140
|
+
server_url: str = DEFAULT_SERVER_URL,
|
|
141
|
+
api_key: Optional[str] = None,
|
|
142
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
143
|
+
) -> dict:
|
|
144
|
+
"""GET /api/inclusion_proof?receipt_id=<rid>&path=<rel_path>."""
|
|
145
|
+
base = _normalise_base(server_url)
|
|
146
|
+
qs = urllib.parse.urlencode({"receipt_id": receipt_id, "path": rel_path})
|
|
147
|
+
headers = _build_headers(api_key, None)
|
|
148
|
+
return _request("GET", f"{base}/api/inclusion_proof?{qs}", headers=headers, timeout=timeout)
|
orphograph/_merkle.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# AUTO-COPIED from server/merkle.py — keep in sync.
|
|
3
|
+
# Source SHA-256 (server/merkle.py at copy time):
|
|
4
|
+
# e68c897382a41e5cb479d00af5fb31e8cb50a45490702ead82d03a25948a87f5
|
|
5
|
+
# If the upstream file changes, recompute with:
|
|
6
|
+
# shasum -a 256 server/merkle.py
|
|
7
|
+
# and update both this banner and this file. The SDK refuses to drift
|
|
8
|
+
# silently from the server's reference implementation.
|
|
9
|
+
"""merkle.py — RFC 6962-compliant Merkle tree for folder anchoring.
|
|
10
|
+
|
|
11
|
+
The office uses this module to commit a whole folder of evidence to a single
|
|
12
|
+
32-byte root, which is then submitted to OpenTimestamps the same way a single
|
|
13
|
+
file hash is. Every file's path is bound into its leaf so that renaming a file
|
|
14
|
+
changes the root — paths are evidence, not incidental metadata.
|
|
15
|
+
|
|
16
|
+
Design notes (intentional, formal):
|
|
17
|
+
|
|
18
|
+
* Leaf: SHA-256(0x00 || rel_path_utf8 || 0x00 || file_sha256)
|
|
19
|
+
* Internal: SHA-256(0x01 || left || right)
|
|
20
|
+
* Odd-level handling: the lone last node is PROMOTED to the next level
|
|
21
|
+
(RFC 6962). The tree never duplicates a node — duplication produces the
|
|
22
|
+
CVE-2012-2459 second-preimage ambiguity, which the office rejects.
|
|
23
|
+
* Algorithm tag: "orphograph-merkle-v1-rfc6962" — embedded in every manifest
|
|
24
|
+
so a future v2 can be distinguished without ambiguity.
|
|
25
|
+
* Streaming: files are hashed in 1 MiB chunks; no file is ever fully buffered.
|
|
26
|
+
* Empty folders are rejected. A single-file folder yields root == leaf hash.
|
|
27
|
+
* Symlinks are skipped (not followed). Hidden dotfiles are included by
|
|
28
|
+
default — evidentiary cases often need them.
|
|
29
|
+
* Paths are normalised to POSIX form (forward slashes) before sorting.
|
|
30
|
+
Unicode normalisation is NOT performed; the receipt is committed as-is
|
|
31
|
+
in NFC by convention. This is a documented v1 limitation.
|
|
32
|
+
|
|
33
|
+
This module is MIT licensed and uses only the Python standard library.
|
|
34
|
+
"""
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import fnmatch
|
|
38
|
+
import hashlib
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import Iterable
|
|
43
|
+
|
|
44
|
+
ALGORITHM = "orphograph-merkle-v1-rfc6962"
|
|
45
|
+
VERSION = 1
|
|
46
|
+
CHUNK_SIZE = 1024 * 1024 # 1 MiB
|
|
47
|
+
|
|
48
|
+
LEAF_PREFIX = b"\x00"
|
|
49
|
+
INTERNAL_PREFIX = b"\x01"
|
|
50
|
+
|
|
51
|
+
# ── scope: the intake record ────────────────────────────────────────
|
|
52
|
+
# A manifest says what WAS captured. It has never said what was deliberately
|
|
53
|
+
# left out, who captured it, or under what instruction — so the first question
|
|
54
|
+
# a hostile reader asks ("what did you omit?") could only be answered by the
|
|
55
|
+
# holder's memory, months later, which is not evidence.
|
|
56
|
+
#
|
|
57
|
+
# `scope` is strictly ADDITIVE metadata. It is NOT an input to the Merkle root
|
|
58
|
+
# — the root derives from the leaves alone — so adding it cannot invalidate any
|
|
59
|
+
# previously issued receipt, and VERSION is deliberately NOT bumped.
|
|
60
|
+
#
|
|
61
|
+
# LIMIT, stated plainly: scope_hex makes the block self-checksummed, so an
|
|
62
|
+
# accidental or careless edit is detectable inside the manifest. It does NOT
|
|
63
|
+
# make scope tamper-evident against a determined party, because the anchored
|
|
64
|
+
# value is still root_hex alone. Binding scope into the anchor is a separate
|
|
65
|
+
# change to the anchoring contract and is not claimed here.
|
|
66
|
+
SCOPE_FIELDS = (
|
|
67
|
+
"exclude",
|
|
68
|
+
"exclude_source",
|
|
69
|
+
"captured_at",
|
|
70
|
+
"captured_by",
|
|
71
|
+
"instruction",
|
|
72
|
+
"omitted_note",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _canonical_scope_bytes(scope: dict) -> bytes:
|
|
77
|
+
"""Deterministic serialisation of the scope block, minus its own hash."""
|
|
78
|
+
payload = {k: scope[k] for k in SCOPE_FIELDS if scope.get(k) not in (None, "")}
|
|
79
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"),
|
|
80
|
+
ensure_ascii=False).encode("utf-8")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def scope_hex(scope: dict) -> str:
|
|
84
|
+
"""SHA-256 over the canonical scope block, excluding ``scope_hex``."""
|
|
85
|
+
return hashlib.sha256(_canonical_scope_bytes(scope)).hexdigest()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_scope(
|
|
89
|
+
*,
|
|
90
|
+
exclude: list[str],
|
|
91
|
+
exclude_source: str = "custom",
|
|
92
|
+
captured_at: str | None = None,
|
|
93
|
+
captured_by: str | None = None,
|
|
94
|
+
instruction: str | None = None,
|
|
95
|
+
omitted_note: str | None = None,
|
|
96
|
+
) -> dict:
|
|
97
|
+
"""Assemble a scope block and stamp it with its own checksum."""
|
|
98
|
+
scope: dict = {
|
|
99
|
+
"exclude": list(exclude),
|
|
100
|
+
"exclude_source": exclude_source,
|
|
101
|
+
}
|
|
102
|
+
for key, value in (("captured_at", captured_at), ("captured_by", captured_by),
|
|
103
|
+
("instruction", instruction), ("omitted_note", omitted_note)):
|
|
104
|
+
if value not in (None, ""):
|
|
105
|
+
scope[key] = str(value)
|
|
106
|
+
scope["scope_hex"] = scope_hex(scope)
|
|
107
|
+
return scope
|
|
108
|
+
|
|
109
|
+
# Default deny-list — files the office considers incidental to the evidence
|
|
110
|
+
# itself (OS detritus, editor backups, build caches). The caller may supply a
|
|
111
|
+
# different list; supplying [] disables exclusion entirely.
|
|
112
|
+
DEFAULT_EXCLUDE = (
|
|
113
|
+
".DS_Store",
|
|
114
|
+
"Thumbs.db",
|
|
115
|
+
"desktop.ini",
|
|
116
|
+
".git/*",
|
|
117
|
+
"node_modules/*",
|
|
118
|
+
"__pycache__/*",
|
|
119
|
+
"*.tmp",
|
|
120
|
+
"*.swp",
|
|
121
|
+
"*.swo",
|
|
122
|
+
"~$*",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _matches_any(rel_path: str, patterns: Iterable[str]) -> bool:
|
|
127
|
+
"""Return True if rel_path is excluded by any glob pattern.
|
|
128
|
+
|
|
129
|
+
A pattern with no slash matches against the basename only (so ``.DS_Store``
|
|
130
|
+
catches the file at any depth). A pattern with a slash matches against the
|
|
131
|
+
full POSIX relative path (so ``.git/*`` catches everything inside .git).
|
|
132
|
+
"""
|
|
133
|
+
name = rel_path.rsplit("/", 1)[-1]
|
|
134
|
+
for pat in patterns:
|
|
135
|
+
if "/" in pat:
|
|
136
|
+
if fnmatch.fnmatch(rel_path, pat):
|
|
137
|
+
return True
|
|
138
|
+
# Also match if any ancestor segment is the prefix dir.
|
|
139
|
+
# e.g. ``.git/*`` should also catch ``.git/sub/file``.
|
|
140
|
+
prefix = pat.rstrip("*").rstrip("/")
|
|
141
|
+
if prefix and (rel_path == prefix or rel_path.startswith(prefix + "/")):
|
|
142
|
+
return True
|
|
143
|
+
else:
|
|
144
|
+
if fnmatch.fnmatch(name, pat):
|
|
145
|
+
return True
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _hash_file(path: Path) -> tuple[bytes, int]:
|
|
150
|
+
"""Stream a file through SHA-256 in 1 MiB chunks. Returns (digest, size)."""
|
|
151
|
+
h = hashlib.sha256()
|
|
152
|
+
size = 0
|
|
153
|
+
with path.open("rb") as f:
|
|
154
|
+
while True:
|
|
155
|
+
chunk = f.read(CHUNK_SIZE)
|
|
156
|
+
if not chunk:
|
|
157
|
+
break
|
|
158
|
+
h.update(chunk)
|
|
159
|
+
size += len(chunk)
|
|
160
|
+
return h.digest(), size
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _leaf_hash(rel_path: str, file_digest: bytes) -> bytes:
|
|
164
|
+
"""Compute the RFC 6962-style leaf hash with the relative path bound in."""
|
|
165
|
+
if len(file_digest) != 32:
|
|
166
|
+
raise ValueError("file_digest must be exactly 32 bytes")
|
|
167
|
+
return hashlib.sha256(
|
|
168
|
+
LEAF_PREFIX + rel_path.encode("utf-8") + b"\x00" + file_digest
|
|
169
|
+
).digest()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _internal_hash(left: bytes, right: bytes) -> bytes:
|
|
173
|
+
"""Compute the RFC 6962-style internal node hash."""
|
|
174
|
+
if len(left) != 32 or len(right) != 32:
|
|
175
|
+
raise ValueError("internal hash inputs must be 32 bytes")
|
|
176
|
+
return hashlib.sha256(INTERNAL_PREFIX + left + right).digest()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _walk_folder(
|
|
180
|
+
root: Path, exclude: Iterable[str]
|
|
181
|
+
) -> list[tuple[str, Path]]:
|
|
182
|
+
"""Walk root and return [(rel_posix_path, absolute_path), ...] sorted.
|
|
183
|
+
|
|
184
|
+
Symlinks are skipped (not followed). The sort is by the UTF-8 byte order
|
|
185
|
+
of the POSIX relative path, which is the canonical order the office uses
|
|
186
|
+
when building the tree.
|
|
187
|
+
"""
|
|
188
|
+
entries: list[tuple[str, Path]] = []
|
|
189
|
+
root = root.resolve()
|
|
190
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
|
191
|
+
# Skip symlinked directories — os.walk would otherwise descend into
|
|
192
|
+
# them with followlinks=True. With followlinks=False they appear in
|
|
193
|
+
# dirnames but are not recursed into. We additionally filter them
|
|
194
|
+
# out so they aren't reported as files either.
|
|
195
|
+
dirnames[:] = [d for d in dirnames if not (Path(dirpath) / d).is_symlink()]
|
|
196
|
+
for fname in filenames:
|
|
197
|
+
abs_path = Path(dirpath) / fname
|
|
198
|
+
if abs_path.is_symlink():
|
|
199
|
+
continue
|
|
200
|
+
if not abs_path.is_file():
|
|
201
|
+
continue
|
|
202
|
+
rel = abs_path.relative_to(root)
|
|
203
|
+
# Normalise to POSIX (forward slashes) regardless of host OS.
|
|
204
|
+
rel_posix = rel.as_posix().replace("\\", "/")
|
|
205
|
+
if _matches_any(rel_posix, exclude):
|
|
206
|
+
continue
|
|
207
|
+
entries.append((rel_posix, abs_path))
|
|
208
|
+
# UTF-8 byte order on the POSIX path string.
|
|
209
|
+
entries.sort(key=lambda e: e[0].encode("utf-8"))
|
|
210
|
+
return entries
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _build_levels(leaves: list[bytes]) -> list[list[bytes]]:
|
|
214
|
+
"""Build the full set of tree levels, bottom-up, RFC 6962 promotion.
|
|
215
|
+
|
|
216
|
+
Returns a list of levels where level[0] is the leaves and level[-1] is
|
|
217
|
+
the single-node root. For a single leaf, returns ``[[leaf]]``.
|
|
218
|
+
"""
|
|
219
|
+
if not leaves:
|
|
220
|
+
raise ValueError("cannot build a tree with no leaves")
|
|
221
|
+
levels: list[list[bytes]] = [list(leaves)]
|
|
222
|
+
while len(levels[-1]) > 1:
|
|
223
|
+
cur = levels[-1]
|
|
224
|
+
nxt: list[bytes] = []
|
|
225
|
+
i = 0
|
|
226
|
+
while i + 1 < len(cur):
|
|
227
|
+
nxt.append(_internal_hash(cur[i], cur[i + 1]))
|
|
228
|
+
i += 2
|
|
229
|
+
if i < len(cur):
|
|
230
|
+
# Odd remainder: PROMOTE the lone node unchanged.
|
|
231
|
+
nxt.append(cur[i])
|
|
232
|
+
levels.append(nxt)
|
|
233
|
+
return levels
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class MerkleTree:
|
|
237
|
+
"""An immutable Merkle tree over a sorted list of (path, file_hash) leaves.
|
|
238
|
+
|
|
239
|
+
Instances are constructed from a folder on disk via :meth:`from_folder` or
|
|
240
|
+
from a previously emitted manifest via :meth:`from_manifest`. The tree
|
|
241
|
+
object holds the leaves, the per-leaf metadata (path, size, file hash),
|
|
242
|
+
and every internal level so inclusion proofs can be served without
|
|
243
|
+
rebuilding.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
__slots__ = ("_leaves_meta", "_levels", "_scope")
|
|
247
|
+
|
|
248
|
+
def __init__(self, leaves_meta: list[dict], levels: list[list[bytes]],
|
|
249
|
+
scope: dict | None = None):
|
|
250
|
+
# Internal constructor — callers use the classmethods.
|
|
251
|
+
self._leaves_meta = leaves_meta
|
|
252
|
+
self._levels = levels
|
|
253
|
+
self._scope = dict(scope) if scope else None
|
|
254
|
+
|
|
255
|
+
# ------------------------------------------------------------------ build
|
|
256
|
+
|
|
257
|
+
@classmethod
|
|
258
|
+
def from_folder(
|
|
259
|
+
cls, root: Path, exclude: list[str] | None = None,
|
|
260
|
+
*,
|
|
261
|
+
captured_by: str | None = None,
|
|
262
|
+
instruction: str | None = None,
|
|
263
|
+
omitted_note: str | None = None,
|
|
264
|
+
captured_at: str | None = None,
|
|
265
|
+
) -> "MerkleTree":
|
|
266
|
+
"""Build a tree by walking ``root`` and streaming each file.
|
|
267
|
+
|
|
268
|
+
``exclude`` defaults to the office's standard deny-list. Passing
|
|
269
|
+
``[]`` disables exclusion entirely; passing a custom list replaces
|
|
270
|
+
the defaults (it does not extend them).
|
|
271
|
+
|
|
272
|
+
The remaining keyword arguments record the SCOPING DECISION — who
|
|
273
|
+
captured, under what instruction, and what was deliberately left out.
|
|
274
|
+
A hash set with no scoping record invites the obvious question in a
|
|
275
|
+
dispute ("what did you omit?"), and a manifest that cannot answer it
|
|
276
|
+
from its own contents makes the holder's memory part of the evidence.
|
|
277
|
+
All are optional; the effective exclude patterns are recorded whether
|
|
278
|
+
or not the caller supplies anything.
|
|
279
|
+
|
|
280
|
+
This does not affect ``root_hex``: the root derives from the leaves
|
|
281
|
+
alone, so scope is metadata ABOUT a tree, never an input to it.
|
|
282
|
+
"""
|
|
283
|
+
root = Path(root)
|
|
284
|
+
if not root.is_dir():
|
|
285
|
+
raise ValueError(f"not a directory: {root}")
|
|
286
|
+
patterns = DEFAULT_EXCLUDE if exclude is None else tuple(exclude)
|
|
287
|
+
entries = _walk_folder(root, patterns)
|
|
288
|
+
if not entries:
|
|
289
|
+
raise ValueError("Empty folders are not supported in v1.")
|
|
290
|
+
|
|
291
|
+
leaves_meta: list[dict] = []
|
|
292
|
+
leaf_hashes: list[bytes] = []
|
|
293
|
+
for rel_path, abs_path in entries:
|
|
294
|
+
file_digest, size = _hash_file(abs_path)
|
|
295
|
+
leaf = _leaf_hash(rel_path, file_digest)
|
|
296
|
+
leaves_meta.append({
|
|
297
|
+
"path": rel_path,
|
|
298
|
+
"file_sha256_hex": file_digest.hex(),
|
|
299
|
+
"leaf_hex": leaf.hex(),
|
|
300
|
+
"size_bytes": size,
|
|
301
|
+
})
|
|
302
|
+
leaf_hashes.append(leaf)
|
|
303
|
+
|
|
304
|
+
levels = _build_levels(leaf_hashes)
|
|
305
|
+
scope = build_scope(
|
|
306
|
+
exclude=list(patterns),
|
|
307
|
+
exclude_source="default" if exclude is None else "custom",
|
|
308
|
+
captured_by=captured_by,
|
|
309
|
+
instruction=instruction,
|
|
310
|
+
omitted_note=omitted_note,
|
|
311
|
+
captured_at=captured_at,
|
|
312
|
+
)
|
|
313
|
+
return cls(leaves_meta, levels, scope)
|
|
314
|
+
|
|
315
|
+
@classmethod
|
|
316
|
+
def from_manifest(cls, manifest: dict) -> "MerkleTree":
|
|
317
|
+
"""Reconstruct a tree from a manifest produced by :meth:`manifest`.
|
|
318
|
+
|
|
319
|
+
The reconstruction recomputes every internal node from the leaf
|
|
320
|
+
hashes in the manifest, then verifies that the recomputed root
|
|
321
|
+
matches the manifest's ``root_hex``. A mismatch raises ValueError —
|
|
322
|
+
the office will not certify a manifest whose root does not derive
|
|
323
|
+
from its own leaves.
|
|
324
|
+
"""
|
|
325
|
+
if not isinstance(manifest, dict):
|
|
326
|
+
raise ValueError("manifest must be a dict")
|
|
327
|
+
if manifest.get("algorithm") != ALGORITHM:
|
|
328
|
+
raise ValueError(f"unsupported algorithm: {manifest.get('algorithm')!r}")
|
|
329
|
+
if manifest.get("version") != VERSION:
|
|
330
|
+
raise ValueError(f"unsupported version: {manifest.get('version')!r}")
|
|
331
|
+
leaves = manifest.get("leaves")
|
|
332
|
+
if not isinstance(leaves, list) or not leaves:
|
|
333
|
+
raise ValueError("manifest leaves must be a non-empty list")
|
|
334
|
+
|
|
335
|
+
leaves_meta: list[dict] = []
|
|
336
|
+
leaf_hashes: list[bytes] = []
|
|
337
|
+
for entry in leaves:
|
|
338
|
+
path = entry["path"]
|
|
339
|
+
file_hex = entry["file_sha256_hex"]
|
|
340
|
+
leaf_hex = entry["leaf_hex"]
|
|
341
|
+
size = int(entry["size_bytes"])
|
|
342
|
+
file_digest = bytes.fromhex(file_hex)
|
|
343
|
+
recomputed = _leaf_hash(path, file_digest)
|
|
344
|
+
if recomputed.hex() != leaf_hex:
|
|
345
|
+
raise ValueError(
|
|
346
|
+
f"manifest leaf hash mismatch for {path!r}: "
|
|
347
|
+
"the stored leaf does not derive from the stored file hash"
|
|
348
|
+
)
|
|
349
|
+
leaves_meta.append({
|
|
350
|
+
"path": path,
|
|
351
|
+
"file_sha256_hex": file_hex,
|
|
352
|
+
"leaf_hex": leaf_hex,
|
|
353
|
+
"size_bytes": size,
|
|
354
|
+
})
|
|
355
|
+
leaf_hashes.append(recomputed)
|
|
356
|
+
|
|
357
|
+
levels = _build_levels(leaf_hashes)
|
|
358
|
+
root_hex_expected = manifest.get("root_hex")
|
|
359
|
+
if levels[-1][0].hex() != root_hex_expected:
|
|
360
|
+
raise ValueError("manifest root_hex does not match recomputed root")
|
|
361
|
+
|
|
362
|
+
# Scope is optional: manifests issued before it existed carry none, and
|
|
363
|
+
# must keep verifying unchanged. When present it is checked against its
|
|
364
|
+
# own recorded hash so a careless edit does not pass silently.
|
|
365
|
+
scope = manifest.get("scope")
|
|
366
|
+
if scope is not None:
|
|
367
|
+
if not isinstance(scope, dict):
|
|
368
|
+
raise ValueError("manifest scope must be an object")
|
|
369
|
+
recorded = scope.get("scope_hex")
|
|
370
|
+
if recorded is not None and recorded != scope_hex(scope):
|
|
371
|
+
raise ValueError(
|
|
372
|
+
"manifest scope_hex does not match the scope block it describes"
|
|
373
|
+
)
|
|
374
|
+
return cls(leaves_meta, levels, scope)
|
|
375
|
+
|
|
376
|
+
# --------------------------------------------------------------- accessors
|
|
377
|
+
|
|
378
|
+
def root(self) -> bytes:
|
|
379
|
+
"""Return the 32-byte root of the tree."""
|
|
380
|
+
return self._levels[-1][0]
|
|
381
|
+
|
|
382
|
+
def root_hex(self) -> str:
|
|
383
|
+
"""Return the root as 64 lowercase hex characters."""
|
|
384
|
+
return self.root().hex()
|
|
385
|
+
|
|
386
|
+
def scope(self) -> dict | None:
|
|
387
|
+
"""The intake record, or None for a tree built without one."""
|
|
388
|
+
return dict(self._scope) if self._scope else None
|
|
389
|
+
|
|
390
|
+
def exclude_patterns(self) -> list[str] | None:
|
|
391
|
+
"""The exclude patterns this tree was built with, if recorded.
|
|
392
|
+
|
|
393
|
+
This is what makes verification self-sufficient: a verifier reads the
|
|
394
|
+
patterns from the manifest instead of requiring the caller to remember
|
|
395
|
+
which list was used at capture time, months earlier, possibly on a
|
|
396
|
+
different machine.
|
|
397
|
+
"""
|
|
398
|
+
if not self._scope:
|
|
399
|
+
return None
|
|
400
|
+
got = self._scope.get("exclude")
|
|
401
|
+
return list(got) if isinstance(got, list) else None
|
|
402
|
+
|
|
403
|
+
def manifest(self) -> dict:
|
|
404
|
+
"""Return a JSON-serialisable manifest describing the tree."""
|
|
405
|
+
out = {
|
|
406
|
+
"algorithm": ALGORITHM,
|
|
407
|
+
"version": VERSION,
|
|
408
|
+
"root_hex": self.root_hex(),
|
|
409
|
+
"leaves": [dict(m) for m in self._leaves_meta],
|
|
410
|
+
}
|
|
411
|
+
# Additive and optional: a manifest emitted before scope existed is
|
|
412
|
+
# still valid, and one emitted now is still readable by an old verifier
|
|
413
|
+
# that ignores unknown keys.
|
|
414
|
+
if self._scope:
|
|
415
|
+
out["scope"] = dict(self._scope)
|
|
416
|
+
return out
|
|
417
|
+
|
|
418
|
+
# --------------------------------------------------------------- proofs
|
|
419
|
+
|
|
420
|
+
def inclusion_proof(self, file_path: str) -> list[tuple[str, str]]:
|
|
421
|
+
"""Return the inclusion proof for ``file_path`` (POSIX relative).
|
|
422
|
+
|
|
423
|
+
The proof is a list of (direction, sibling_hex) tuples ordered from
|
|
424
|
+
the leaf upward. ``direction == "L"`` means the sibling sits on the
|
|
425
|
+
LEFT of the running hash at that level; ``"R"`` means it sits on the
|
|
426
|
+
right. A promoted (lone-last) node contributes no proof step at that
|
|
427
|
+
level — there is no sibling to record.
|
|
428
|
+
"""
|
|
429
|
+
idx = None
|
|
430
|
+
for i, m in enumerate(self._leaves_meta):
|
|
431
|
+
if m["path"] == file_path:
|
|
432
|
+
idx = i
|
|
433
|
+
break
|
|
434
|
+
if idx is None:
|
|
435
|
+
raise ValueError(f"path not in tree: {file_path!r}")
|
|
436
|
+
|
|
437
|
+
proof: list[tuple[str, str]] = []
|
|
438
|
+
for level in self._levels[:-1]:
|
|
439
|
+
# Was this node the lone-last (promoted) node at this level?
|
|
440
|
+
if idx == len(level) - 1 and len(level) % 2 == 1:
|
|
441
|
+
# No sibling — promote to next level with the same index/2.
|
|
442
|
+
idx = idx // 2
|
|
443
|
+
continue
|
|
444
|
+
if idx % 2 == 0:
|
|
445
|
+
# Current is left, sibling is on the right.
|
|
446
|
+
sibling = level[idx + 1]
|
|
447
|
+
proof.append(("R", sibling.hex()))
|
|
448
|
+
else:
|
|
449
|
+
# Current is right, sibling is on the left.
|
|
450
|
+
sibling = level[idx - 1]
|
|
451
|
+
proof.append(("L", sibling.hex()))
|
|
452
|
+
idx = idx // 2
|
|
453
|
+
return proof
|
|
454
|
+
|
|
455
|
+
@staticmethod
|
|
456
|
+
def verify_inclusion(
|
|
457
|
+
file_hash: bytes,
|
|
458
|
+
rel_path: str,
|
|
459
|
+
proof: list[tuple[str, str]],
|
|
460
|
+
root: bytes,
|
|
461
|
+
) -> bool:
|
|
462
|
+
"""Verify a file's inclusion against a known root.
|
|
463
|
+
|
|
464
|
+
``file_hash`` is the raw SHA-256 of the file content (not the leaf).
|
|
465
|
+
``rel_path`` is the POSIX path under which the file was committed.
|
|
466
|
+
``proof`` is the list of (direction, sibling_hex) tuples returned by
|
|
467
|
+
:meth:`inclusion_proof`. ``root`` is the 32-byte tree root.
|
|
468
|
+
"""
|
|
469
|
+
try:
|
|
470
|
+
current = _leaf_hash(rel_path, file_hash)
|
|
471
|
+
except ValueError:
|
|
472
|
+
return False
|
|
473
|
+
if not isinstance(root, (bytes, bytearray)) or len(root) != 32:
|
|
474
|
+
return False
|
|
475
|
+
for step in proof:
|
|
476
|
+
if (
|
|
477
|
+
not isinstance(step, tuple)
|
|
478
|
+
or len(step) != 2
|
|
479
|
+
or step[0] not in ("L", "R")
|
|
480
|
+
):
|
|
481
|
+
return False
|
|
482
|
+
direction, sibling_hex = step
|
|
483
|
+
try:
|
|
484
|
+
sibling = bytes.fromhex(sibling_hex)
|
|
485
|
+
except ValueError:
|
|
486
|
+
return False
|
|
487
|
+
if len(sibling) != 32:
|
|
488
|
+
return False
|
|
489
|
+
if direction == "L":
|
|
490
|
+
current = _internal_hash(sibling, current)
|
|
491
|
+
else:
|
|
492
|
+
current = _internal_hash(current, sibling)
|
|
493
|
+
return current == bytes(root)
|
orphograph/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: orphograph
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Anchor folders to Bitcoin via the Orphograph hosted service. File contents stay on the local machine.
|
|
5
|
+
Author: the Orphograph contributors
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 the Orphograph contributors
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://orphograph.com
|
|
29
|
+
Project-URL: Documentation, https://orphograph.com/method/architecture.html
|
|
30
|
+
Keywords: bitcoin,opentimestamps,merkle,notarization,provenance
|
|
31
|
+
Classifier: Development Status :: 4 - Beta
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Programming Language :: Python :: 3
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
38
|
+
Classifier: Topic :: Security :: Cryptography
|
|
39
|
+
Classifier: Topic :: System :: Archiving
|
|
40
|
+
Requires-Python: >=3.9
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
License-File: LICENSE
|
|
43
|
+
Dynamic: license-file
|
|
44
|
+
|
|
45
|
+
# orphograph
|
|
46
|
+
|
|
47
|
+
A Python SDK for anchoring a local folder to the Bitcoin chain through the
|
|
48
|
+
[Orphograph](https://orphograph.com) hosted service.
|
|
49
|
+
|
|
50
|
+
- License: MIT
|
|
51
|
+
- Python: 3.9 or newer
|
|
52
|
+
- Runtime dependencies: Python standard library only
|
|
53
|
+
|
|
54
|
+
## Privacy contract
|
|
55
|
+
|
|
56
|
+
The library does not transmit file contents. For each file the SDK reads
|
|
57
|
+
the bytes locally, streams them through SHA-256 in one megabyte chunks,
|
|
58
|
+
and commits the digest into an RFC 6962 Merkle leaf bound to the file's
|
|
59
|
+
POSIX relative path. Only the resulting manifest (paths, per-file digests,
|
|
60
|
+
leaf hashes, and the 32-byte root) is sent across the network. The
|
|
61
|
+
verification path is symmetric: the root is recomputed locally from the
|
|
62
|
+
folder on disk and compared to the root recorded in the receipt.
|
|
63
|
+
|
|
64
|
+
The Merkle module is a verbatim copy of the server's reference
|
|
65
|
+
implementation, carrying the source SHA-256 in its header so divergence
|
|
66
|
+
from the canonical algorithm is immediately visible.
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
pip install orphograph
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Anchor a folder
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from orphograph import anchor_folder
|
|
78
|
+
|
|
79
|
+
result = anchor_folder("/path/to/folder")
|
|
80
|
+
# {
|
|
81
|
+
# "receipt_id": "...",
|
|
82
|
+
# "root_hex": "...",
|
|
83
|
+
# "leaf_count": 42,
|
|
84
|
+
# "calendars_ok": 5,
|
|
85
|
+
# "calendars_total": 5,
|
|
86
|
+
# }
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Optional arguments:
|
|
90
|
+
|
|
91
|
+
| Argument | Purpose |
|
|
92
|
+
| -------------- | -------------------------------------------------------- |
|
|
93
|
+
| `server_url` | Base URL of the service (default `https://orphograph.com`). |
|
|
94
|
+
| `api_key` | Sent as `X-Orpho-Api-Key` when present. |
|
|
95
|
+
| `client_label` | Short free-form label persisted with the receipt. |
|
|
96
|
+
| `exclude` | Sequence of `fnmatch` patterns. `None` selects the default deny list (OS detritus, editor backups, build caches). Passing `[]` disables exclusion. |
|
|
97
|
+
|
|
98
|
+
## Verify a folder
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from orphograph import verify_folder
|
|
102
|
+
|
|
103
|
+
ok = verify_folder("/path/to/folder", receipt_id="...")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The folder is walked locally, the Merkle root is recomputed, and the SDK
|
|
107
|
+
returns `True` only if the recomputed root equals the root recorded in
|
|
108
|
+
the receipt's manifest.
|
|
109
|
+
|
|
110
|
+
## Inclusion proofs
|
|
111
|
+
|
|
112
|
+
A folder receipt can be queried for a proof that a single file belonged
|
|
113
|
+
to the anchored tree. The proof is verified locally; no further network
|
|
114
|
+
call is required to confirm it.
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
from orphograph import inclusion_proof, verify_inclusion
|
|
118
|
+
|
|
119
|
+
proof = inclusion_proof(receipt_id="...", path="sub/photo.jpg")
|
|
120
|
+
ok = verify_inclusion(
|
|
121
|
+
file_path="/path/to/sub/photo.jpg",
|
|
122
|
+
rel_path="sub/photo.jpg",
|
|
123
|
+
proof=proof["proof"],
|
|
124
|
+
root_hex=proof["root_hex"],
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Command line
|
|
129
|
+
|
|
130
|
+
The package installs an `orphograph` console script and is also runnable
|
|
131
|
+
as a module.
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
python -m orphograph anchor /path/to/folder
|
|
135
|
+
python -m orphograph verify /path/to/folder <receipt_id>
|
|
136
|
+
python -m orphograph inclusion-proof <receipt_id> <posix/rel/path>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Each subcommand writes a single line of JSON to standard output. The
|
|
140
|
+
`verify` subcommand exits with status `0` on a match and `1` on a
|
|
141
|
+
mismatch.
|
|
142
|
+
|
|
143
|
+
Environment variables:
|
|
144
|
+
|
|
145
|
+
| Variable | Purpose |
|
|
146
|
+
| ------------------ | --------------------------------------------- |
|
|
147
|
+
| `ORPHO_SERVER_URL` | Default base URL. |
|
|
148
|
+
| `ORPHO_API_KEY` | Default API key (sent as `X-Orpho-Api-Key`). |
|
|
149
|
+
|
|
150
|
+
## Algorithm
|
|
151
|
+
|
|
152
|
+
The Merkle construction is RFC 6962 with a domain-separated leaf
|
|
153
|
+
(`0x00 || rel_path_utf8 || 0x00 || file_sha256`) and a domain-separated
|
|
154
|
+
internal node (`0x01 || left || right`). Odd-level remainders are
|
|
155
|
+
promoted, never duplicated, to avoid the second-preimage ambiguity of
|
|
156
|
+
the duplicate-last construction. The algorithm tag
|
|
157
|
+
`orphograph-merkle-v1-rfc6962` is embedded in every manifest.
|
|
158
|
+
|
|
159
|
+
## License
|
|
160
|
+
|
|
161
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
orphograph/__init__.py,sha256=KRtS14L18yhuTskbh_N_uG7m27wMwxiyWDXOy_4vNtM,6243
|
|
2
|
+
orphograph/__main__.py,sha256=ojo5MJFMfWYDSnU5bzsHD9aMAFOrsaaNBPPYsaaMerE,128
|
|
3
|
+
orphograph/_cli.py,sha256=hf7WVDf2vpWUS-OTe9DqTqjUYnJ88Q5h_8486Bfgdbc,4684
|
|
4
|
+
orphograph/_client.py,sha256=wXt0122pvVL6I4GX7CXdMo7d2s9RYAUrf2ilFsBMU9E,5613
|
|
5
|
+
orphograph/_merkle.py,sha256=uNFfUPA1OdByhriNxeP7S6-aY_pD1lz73zWfRbqOx0o,19939
|
|
6
|
+
orphograph/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
orphograph-0.1.0.dist-info/licenses/LICENSE,sha256=AFKEEvMxB7rzLplVIBRip-2usNwTa6NkKSmoKYpfbpM,1084
|
|
8
|
+
orphograph-0.1.0.dist-info/METADATA,sha256=Rnt_qOWJekfD3iM4RfoMRJPex3neheZPIedtpFM65qU,5955
|
|
9
|
+
orphograph-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
orphograph-0.1.0.dist-info/entry_points.txt,sha256=b31Ke7uY6DQ-HiQda-uSdAgKtVd3xvVcV4QtDyXeriE,52
|
|
11
|
+
orphograph-0.1.0.dist-info/top_level.txt,sha256=A_gaRwagHQPhdQry16YNuBGHkKLUhzihvHthpxNiclc,11
|
|
12
|
+
orphograph-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 the Orphograph contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
orphograph
|