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 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
@@ -0,0 +1,5 @@
1
+ """Entry point for ``python -m orphograph``."""
2
+ from ._cli import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ orphograph = orphograph._cli:main
@@ -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