matrym-hashchain 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Josh Evans (MatrymLabs)
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,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: matrym-hashchain
3
+ Version: 0.1.0
4
+ Summary: A tamper-evident, append-only, hash-chained ledger. Dependency-free (stdlib only).
5
+ Author: Josh Evans (MatrymLabs)
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/MatrymLabs/matrym-hashchain
8
+ Project-URL: Repository, https://github.com/MatrymLabs/matrym-hashchain
9
+ Project-URL: Changelog, https://github.com/MatrymLabs/matrym-hashchain/blob/main/CHANGELOG.md
10
+ Keywords: audit,integrity,tamper-evident,hash-chain,append-only,ledger,provenance,compliance
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Security
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8; extra == "dev"
28
+ Requires-Dist: ruff>=0.6; extra == "dev"
29
+ Requires-Dist: mypy>=1.11; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # matrym-hashchain
33
+
34
+ [![CI](https://github.com/MatrymLabs/matrym-hashchain/actions/workflows/ci.yml/badge.svg)](https://github.com/MatrymLabs/matrym-hashchain/actions/workflows/ci.yml)
35
+ ![Python](https://img.shields.io/badge/python-3.10+-blue)
36
+ ![License](https://img.shields.io/badge/license-MIT-green)
37
+ ![Dependencies](https://img.shields.io/badge/dependencies-none-brightgreen)
38
+
39
+ **A tamper-evident, append-only, hash-chained ledger. Dependency-free (stdlib only).**
40
+
41
+ Each entry carries a SHA-256 over its own payload **and** the previous entry's hash, so any later
42
+ edit, reorder, or deletion of a *past* record is detected the next time the log is read. It's a
43
+ file-simple, zero-dependency primitive for **audit trails, transaction logs, and chain-of-custody
44
+ records** - the kind of thing a compliance, finance, or records system needs and shouldn't hand-roll.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install matrym-hashchain # once released to PyPI
50
+ pip install git+https://github.com/MatrymLabs/matrym-hashchain # or straight from GitHub, today
51
+ ```
52
+
53
+ PyPI publishing is wired up via [trusted publishing](RELEASING.md) (no stored token); the
54
+ `pip install matrym-hashchain` line goes live with the first tagged release.
55
+
56
+ ## Use
57
+
58
+ ```python
59
+ from pathlib import Path
60
+ from hashchain import append, read, verify, HashChainError
61
+
62
+ ledger = Path("audit.jsonl")
63
+
64
+ append(ledger, {"event": "created", "who": "alice"})
65
+ append(ledger, {"event": "approved", "who": "bob"})
66
+
67
+ for entry in read(ledger): # read() verifies the whole chain as it goes
68
+ print(entry.seq, entry.payload)
69
+
70
+ verify(ledger) # True -- the history reads clean end to end
71
+
72
+ # If anyone edits, reorders, or deletes a past record...
73
+ ledger.write_text(ledger.read_text().replace("alice", "mallory"))
74
+ verify(ledger) # False
75
+ read(ledger) # raises HashChainError: record 0 was tampered ...
76
+ ```
77
+
78
+ The store is plain JSON Lines (`.jsonl`) - one record per line, human-readable, greppable, and
79
+ portable. No database, no daemon, no dependencies.
80
+
81
+ ## What it guarantees (and what it doesn't)
82
+
83
+ This proves **integrity**, honestly labelled:
84
+
85
+ | Attack | Detected? |
86
+ |---|---|
87
+ | A payload was edited | ✅ content-hash mismatch |
88
+ | Records were reordered or one was inserted | ✅ sequence / prior-hash mismatch |
89
+ | A *middle* record was deleted | ✅ the next record's prior-hash no longer links |
90
+ | The *last* record(s) were dropped | ❌ not by the chain alone - anchor the head hash elsewhere (a receipt, a second store) if truncation must be caught |
91
+ | Who wrote a record (authenticity) | ❌ integrity is not authenticity - sign the head hash (HMAC or a key) to prove authorship |
92
+
93
+ Any break **fails loud** with `HashChainError` rather than returning a dishonest history.
94
+
95
+ ## API
96
+
97
+ - `append(path, payload) -> Entry` - validate, hash-chain, and append one record. Verifies the
98
+ chain *before* extending it, so you can't quietly append onto an already-tampered log.
99
+ - `read(path) -> list[Entry]` - read every record, verifying as it goes. Empty/missing store -> `[]`.
100
+ - `verify(path) -> bool` - `True` if the ledger reads clean end to end, `False` if broken.
101
+ - `content_hash(mapping) -> str` - the canonical SHA-256 (sorted keys, no whitespace) the chain uses.
102
+ - `Entry(seq, payload, prior_hash, content_hash)` - one link; `HashChainError` - the loud failure.
103
+
104
+ ## Provenance
105
+
106
+ This is a **MatrymLabs Hardware Store part**: a pattern first proven in the
107
+ [CodeForge](https://github.com/MatrymLabs/codeforge) MUD engine (the "Chronicle" - the game's
108
+ tamper-evident memory), then reused across the fleet (a federal guidance-check ledger; an AI-triage
109
+ decision audit trail). Published standalone so any project can reuse the same tested primitive -
110
+ *one well-made part, many jobs.*
111
+
112
+ ## License
113
+
114
+ MIT
@@ -0,0 +1,83 @@
1
+ # matrym-hashchain
2
+
3
+ [![CI](https://github.com/MatrymLabs/matrym-hashchain/actions/workflows/ci.yml/badge.svg)](https://github.com/MatrymLabs/matrym-hashchain/actions/workflows/ci.yml)
4
+ ![Python](https://img.shields.io/badge/python-3.10+-blue)
5
+ ![License](https://img.shields.io/badge/license-MIT-green)
6
+ ![Dependencies](https://img.shields.io/badge/dependencies-none-brightgreen)
7
+
8
+ **A tamper-evident, append-only, hash-chained ledger. Dependency-free (stdlib only).**
9
+
10
+ Each entry carries a SHA-256 over its own payload **and** the previous entry's hash, so any later
11
+ edit, reorder, or deletion of a *past* record is detected the next time the log is read. It's a
12
+ file-simple, zero-dependency primitive for **audit trails, transaction logs, and chain-of-custody
13
+ records** - the kind of thing a compliance, finance, or records system needs and shouldn't hand-roll.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install matrym-hashchain # once released to PyPI
19
+ pip install git+https://github.com/MatrymLabs/matrym-hashchain # or straight from GitHub, today
20
+ ```
21
+
22
+ PyPI publishing is wired up via [trusted publishing](RELEASING.md) (no stored token); the
23
+ `pip install matrym-hashchain` line goes live with the first tagged release.
24
+
25
+ ## Use
26
+
27
+ ```python
28
+ from pathlib import Path
29
+ from hashchain import append, read, verify, HashChainError
30
+
31
+ ledger = Path("audit.jsonl")
32
+
33
+ append(ledger, {"event": "created", "who": "alice"})
34
+ append(ledger, {"event": "approved", "who": "bob"})
35
+
36
+ for entry in read(ledger): # read() verifies the whole chain as it goes
37
+ print(entry.seq, entry.payload)
38
+
39
+ verify(ledger) # True -- the history reads clean end to end
40
+
41
+ # If anyone edits, reorders, or deletes a past record...
42
+ ledger.write_text(ledger.read_text().replace("alice", "mallory"))
43
+ verify(ledger) # False
44
+ read(ledger) # raises HashChainError: record 0 was tampered ...
45
+ ```
46
+
47
+ The store is plain JSON Lines (`.jsonl`) - one record per line, human-readable, greppable, and
48
+ portable. No database, no daemon, no dependencies.
49
+
50
+ ## What it guarantees (and what it doesn't)
51
+
52
+ This proves **integrity**, honestly labelled:
53
+
54
+ | Attack | Detected? |
55
+ |---|---|
56
+ | A payload was edited | ✅ content-hash mismatch |
57
+ | Records were reordered or one was inserted | ✅ sequence / prior-hash mismatch |
58
+ | A *middle* record was deleted | ✅ the next record's prior-hash no longer links |
59
+ | The *last* record(s) were dropped | ❌ not by the chain alone - anchor the head hash elsewhere (a receipt, a second store) if truncation must be caught |
60
+ | Who wrote a record (authenticity) | ❌ integrity is not authenticity - sign the head hash (HMAC or a key) to prove authorship |
61
+
62
+ Any break **fails loud** with `HashChainError` rather than returning a dishonest history.
63
+
64
+ ## API
65
+
66
+ - `append(path, payload) -> Entry` - validate, hash-chain, and append one record. Verifies the
67
+ chain *before* extending it, so you can't quietly append onto an already-tampered log.
68
+ - `read(path) -> list[Entry]` - read every record, verifying as it goes. Empty/missing store -> `[]`.
69
+ - `verify(path) -> bool` - `True` if the ledger reads clean end to end, `False` if broken.
70
+ - `content_hash(mapping) -> str` - the canonical SHA-256 (sorted keys, no whitespace) the chain uses.
71
+ - `Entry(seq, payload, prior_hash, content_hash)` - one link; `HashChainError` - the loud failure.
72
+
73
+ ## Provenance
74
+
75
+ This is a **MatrymLabs Hardware Store part**: a pattern first proven in the
76
+ [CodeForge](https://github.com/MatrymLabs/codeforge) MUD engine (the "Chronicle" - the game's
77
+ tamper-evident memory), then reused across the fleet (a federal guidance-check ledger; an AI-triage
78
+ decision audit trail). Published standalone so any project can reuse the same tested primitive -
79
+ *one well-made part, many jobs.*
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "matrym-hashchain"
7
+ version = "0.1.0"
8
+ description = "A tamper-evident, append-only, hash-chained ledger. Dependency-free (stdlib only)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Josh Evans (MatrymLabs)" }]
13
+ keywords = [
14
+ "audit",
15
+ "integrity",
16
+ "tamper-evident",
17
+ "hash-chain",
18
+ "append-only",
19
+ "ledger",
20
+ "provenance",
21
+ "compliance",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 4 - Beta",
25
+ "Intended Audience :: Developers",
26
+ "License :: OSI Approved :: MIT License",
27
+ "Operating System :: OS Independent",
28
+ "Programming Language :: Python :: 3 :: Only",
29
+ "Programming Language :: Python :: 3.10",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Programming Language :: Python :: 3.13",
33
+ "Topic :: Security",
34
+ "Topic :: Software Development :: Libraries",
35
+ "Typing :: Typed",
36
+ ]
37
+ dependencies = []
38
+
39
+ [project.optional-dependencies]
40
+ dev = ["pytest>=8", "ruff>=0.6", "mypy>=1.11"]
41
+
42
+ [project.urls]
43
+ Homepage = "https://github.com/MatrymLabs/matrym-hashchain"
44
+ Repository = "https://github.com/MatrymLabs/matrym-hashchain"
45
+ Changelog = "https://github.com/MatrymLabs/matrym-hashchain/blob/main/CHANGELOG.md"
46
+
47
+ [tool.setuptools.packages.find]
48
+ where = ["src"]
49
+
50
+ [tool.setuptools.package-data]
51
+ hashchain = ["py.typed"]
52
+
53
+ [tool.ruff]
54
+ line-length = 100
55
+ target-version = "py310"
56
+
57
+ [tool.ruff.lint]
58
+ select = ["E", "F", "I", "UP", "B", "SIM"]
59
+
60
+ [tool.mypy]
61
+ python_version = "3.10"
62
+ strict = true
63
+ files = ["src", "tests"]
64
+
65
+ [tool.pytest.ini_options]
66
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,142 @@
1
+ """A tamper-evident, append-only, hash-chained ledger. Dependency-free (stdlib only).
2
+
3
+ Each entry carries a sha256 over its own payload AND the previous entry's hash, so any later edit,
4
+ reorder, or removal of a PAST record is detected the next time the log is read. A file-simple,
5
+ zero-dependency primitive for audit trails, transaction logs, and chain-of-custody records.
6
+
7
+ from pathlib import Path
8
+ from hashchain import append, read, verify
9
+
10
+ p = Path("audit.jsonl")
11
+ append(p, {"event": "created", "who": "alice"})
12
+ append(p, {"event": "approved", "who": "bob"})
13
+ verify(p) # True -- the history reads clean end to end
14
+
15
+ Honest bounds -- this proves INTEGRITY, not everything:
16
+ - Detected: a changed payload (hash mismatch), a reordered or inserted record, a deleted MIDDLE
17
+ record (the next record's prior-hash no longer links).
18
+ - NOT detected by the chain alone: dropping the LAST record(s) -- anchor the head hash elsewhere
19
+ (a receipt, a second store) if truncation must be caught. And integrity is not authenticity:
20
+ sign the head hash to prove WHO wrote it.
21
+ Any break fails loud with `HashChainError` rather than returning a dishonest history.
22
+
23
+ Provenance: this is a MatrymLabs Hardware Store part -- a pattern first proven in the CodeForge MUD
24
+ engine (the "Chronicle", the game's tamper-evident memory) and reused across the fleet (a federal
25
+ guidance-check ledger; an AI-triage decision audit trail). Published standalone so any project can
26
+ reuse the same tested primitive.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import json
33
+ from collections.abc import Mapping
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ __version__ = "0.1.0"
39
+ __all__ = [
40
+ "GENESIS",
41
+ "Entry",
42
+ "HashChainError",
43
+ "append",
44
+ "content_hash",
45
+ "read",
46
+ "verify",
47
+ ]
48
+
49
+ GENESIS = "" # the prior_hash of the first entry: nothing precedes it
50
+ _FIELDS = ("seq", "payload", "prior_hash", "content_hash")
51
+
52
+
53
+ class HashChainError(Exception):
54
+ """Raised when the ledger is malformed or its chain is broken. Names the exact record."""
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Entry:
59
+ """One entry in the chain: its position, its data, and the hashes that bind it to the past."""
60
+
61
+ seq: int
62
+ payload: dict[str, Any]
63
+ prior_hash: str
64
+ content_hash: str
65
+
66
+
67
+ def content_hash(payload: Mapping[str, Any]) -> str:
68
+ """A deterministic sha256 over a JSON-serializable mapping (canonical: sorted keys, compact)."""
69
+ canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
70
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
71
+
72
+
73
+ def _digest(seq: int, payload: Mapping[str, Any], prior_hash: str) -> str:
74
+ return content_hash({"seq": seq, "payload": payload, "prior_hash": prior_hash})
75
+
76
+
77
+ def append(path: Path, payload: dict[str, Any]) -> Entry:
78
+ """Validate, hash-chain, and append one record; return the new Entry. The chain is verified
79
+ before it is extended, so appending to a tampered ledger fails loud rather than hiding it."""
80
+ if not isinstance(payload, dict):
81
+ raise HashChainError("payload must be a JSON object (dict)")
82
+ existing = read(path) # verifies the chain first
83
+ prior = existing[-1].content_hash if existing else GENESIS
84
+ seq = len(existing)
85
+ try:
86
+ digest = _digest(seq, payload, prior)
87
+ except (TypeError, ValueError) as exc:
88
+ raise HashChainError(f"payload is not JSON-serializable: {exc}") from exc
89
+ entry = Entry(seq=seq, payload=payload, prior_hash=prior, content_hash=digest)
90
+ path.parent.mkdir(parents=True, exist_ok=True)
91
+ with path.open("a", encoding="utf-8") as handle:
92
+ handle.write(json.dumps(_as_row(entry), sort_keys=True) + "\n")
93
+ return entry
94
+
95
+
96
+ def read(path: Path) -> list[Entry]:
97
+ """Read every record, verifying the chain as it goes. Fails loud (`HashChainError`) on a
98
+ tampered payload, a broken/reordered chain, or a malformed line. Empty/missing store -> []."""
99
+ if not path.exists():
100
+ return []
101
+ entries: list[Entry] = []
102
+ prev_hash = GENESIS
103
+ for lineno, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
104
+ line = raw.strip()
105
+ if not line:
106
+ continue
107
+ try:
108
+ row = json.loads(line)
109
+ except json.JSONDecodeError as exc:
110
+ raise HashChainError(f"line {lineno} is unreadable JSON: {exc}") from exc
111
+ if not isinstance(row, dict) or not all(field in row for field in _FIELDS):
112
+ raise HashChainError(f"line {lineno} is a malformed record (missing fields)")
113
+ expected_seq = len(entries)
114
+ if row["seq"] != expected_seq:
115
+ raise HashChainError(
116
+ f"record {row['seq']} is out of order (expected seq {expected_seq})"
117
+ )
118
+ if _digest(row["seq"], row["payload"], row["prior_hash"]) != row["content_hash"]:
119
+ raise HashChainError(f"record {row['seq']} was tampered: content hash mismatch")
120
+ if row["prior_hash"] != prev_hash:
121
+ raise HashChainError(f"broken chain at record {row['seq']}: prior hash does not link")
122
+ entries.append(Entry(row["seq"], row["payload"], row["prior_hash"], row["content_hash"]))
123
+ prev_hash = row["content_hash"]
124
+ return entries
125
+
126
+
127
+ def verify(path: Path) -> bool:
128
+ """True if the ledger reads clean end to end, False if the chain is broken or a line is bad."""
129
+ try:
130
+ read(path)
131
+ return True
132
+ except HashChainError:
133
+ return False
134
+
135
+
136
+ def _as_row(entry: Entry) -> dict[str, Any]:
137
+ return {
138
+ "seq": entry.seq,
139
+ "payload": entry.payload,
140
+ "prior_hash": entry.prior_hash,
141
+ "content_hash": entry.content_hash,
142
+ }
File without changes
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: matrym-hashchain
3
+ Version: 0.1.0
4
+ Summary: A tamper-evident, append-only, hash-chained ledger. Dependency-free (stdlib only).
5
+ Author: Josh Evans (MatrymLabs)
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/MatrymLabs/matrym-hashchain
8
+ Project-URL: Repository, https://github.com/MatrymLabs/matrym-hashchain
9
+ Project-URL: Changelog, https://github.com/MatrymLabs/matrym-hashchain/blob/main/CHANGELOG.md
10
+ Keywords: audit,integrity,tamper-evident,hash-chain,append-only,ledger,provenance,compliance
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Security
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8; extra == "dev"
28
+ Requires-Dist: ruff>=0.6; extra == "dev"
29
+ Requires-Dist: mypy>=1.11; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # matrym-hashchain
33
+
34
+ [![CI](https://github.com/MatrymLabs/matrym-hashchain/actions/workflows/ci.yml/badge.svg)](https://github.com/MatrymLabs/matrym-hashchain/actions/workflows/ci.yml)
35
+ ![Python](https://img.shields.io/badge/python-3.10+-blue)
36
+ ![License](https://img.shields.io/badge/license-MIT-green)
37
+ ![Dependencies](https://img.shields.io/badge/dependencies-none-brightgreen)
38
+
39
+ **A tamper-evident, append-only, hash-chained ledger. Dependency-free (stdlib only).**
40
+
41
+ Each entry carries a SHA-256 over its own payload **and** the previous entry's hash, so any later
42
+ edit, reorder, or deletion of a *past* record is detected the next time the log is read. It's a
43
+ file-simple, zero-dependency primitive for **audit trails, transaction logs, and chain-of-custody
44
+ records** - the kind of thing a compliance, finance, or records system needs and shouldn't hand-roll.
45
+
46
+ ## Install
47
+
48
+ ```bash
49
+ pip install matrym-hashchain # once released to PyPI
50
+ pip install git+https://github.com/MatrymLabs/matrym-hashchain # or straight from GitHub, today
51
+ ```
52
+
53
+ PyPI publishing is wired up via [trusted publishing](RELEASING.md) (no stored token); the
54
+ `pip install matrym-hashchain` line goes live with the first tagged release.
55
+
56
+ ## Use
57
+
58
+ ```python
59
+ from pathlib import Path
60
+ from hashchain import append, read, verify, HashChainError
61
+
62
+ ledger = Path("audit.jsonl")
63
+
64
+ append(ledger, {"event": "created", "who": "alice"})
65
+ append(ledger, {"event": "approved", "who": "bob"})
66
+
67
+ for entry in read(ledger): # read() verifies the whole chain as it goes
68
+ print(entry.seq, entry.payload)
69
+
70
+ verify(ledger) # True -- the history reads clean end to end
71
+
72
+ # If anyone edits, reorders, or deletes a past record...
73
+ ledger.write_text(ledger.read_text().replace("alice", "mallory"))
74
+ verify(ledger) # False
75
+ read(ledger) # raises HashChainError: record 0 was tampered ...
76
+ ```
77
+
78
+ The store is plain JSON Lines (`.jsonl`) - one record per line, human-readable, greppable, and
79
+ portable. No database, no daemon, no dependencies.
80
+
81
+ ## What it guarantees (and what it doesn't)
82
+
83
+ This proves **integrity**, honestly labelled:
84
+
85
+ | Attack | Detected? |
86
+ |---|---|
87
+ | A payload was edited | ✅ content-hash mismatch |
88
+ | Records were reordered or one was inserted | ✅ sequence / prior-hash mismatch |
89
+ | A *middle* record was deleted | ✅ the next record's prior-hash no longer links |
90
+ | The *last* record(s) were dropped | ❌ not by the chain alone - anchor the head hash elsewhere (a receipt, a second store) if truncation must be caught |
91
+ | Who wrote a record (authenticity) | ❌ integrity is not authenticity - sign the head hash (HMAC or a key) to prove authorship |
92
+
93
+ Any break **fails loud** with `HashChainError` rather than returning a dishonest history.
94
+
95
+ ## API
96
+
97
+ - `append(path, payload) -> Entry` - validate, hash-chain, and append one record. Verifies the
98
+ chain *before* extending it, so you can't quietly append onto an already-tampered log.
99
+ - `read(path) -> list[Entry]` - read every record, verifying as it goes. Empty/missing store -> `[]`.
100
+ - `verify(path) -> bool` - `True` if the ledger reads clean end to end, `False` if broken.
101
+ - `content_hash(mapping) -> str` - the canonical SHA-256 (sorted keys, no whitespace) the chain uses.
102
+ - `Entry(seq, payload, prior_hash, content_hash)` - one link; `HashChainError` - the loud failure.
103
+
104
+ ## Provenance
105
+
106
+ This is a **MatrymLabs Hardware Store part**: a pattern first proven in the
107
+ [CodeForge](https://github.com/MatrymLabs/codeforge) MUD engine (the "Chronicle" - the game's
108
+ tamper-evident memory), then reused across the fleet (a federal guidance-check ledger; an AI-triage
109
+ decision audit trail). Published standalone so any project can reuse the same tested primitive -
110
+ *one well-made part, many jobs.*
111
+
112
+ ## License
113
+
114
+ MIT
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/hashchain/__init__.py
5
+ src/hashchain/py.typed
6
+ src/matrym_hashchain.egg-info/PKG-INFO
7
+ src/matrym_hashchain.egg-info/SOURCES.txt
8
+ src/matrym_hashchain.egg-info/dependency_links.txt
9
+ src/matrym_hashchain.egg-info/requires.txt
10
+ src/matrym_hashchain.egg-info/top_level.txt
11
+ tests/test_hashchain.py
@@ -0,0 +1,5 @@
1
+
2
+ [dev]
3
+ pytest>=8
4
+ ruff>=0.6
5
+ mypy>=1.11
@@ -0,0 +1,119 @@
1
+ """Test twin for hashchain -- the tamper-evident append-only ledger.
2
+
3
+ Acceptance: records append and read back in order; each entry chains to its predecessor; empty and
4
+ missing stores read clean. Refusal (the whole point of a tamper-evident log): an edited payload, a
5
+ reordered chain, a deleted middle record, a malformed line, and a non-serializable payload all fail
6
+ loud with HashChainError rather than returning a dishonest history. Every test uses tmp_path.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from hashchain import GENESIS, HashChainError, append, content_hash, read, verify
16
+
17
+
18
+ def _ledger(root: Path) -> Path:
19
+ return root / "chain.jsonl"
20
+
21
+
22
+ # --- acceptance --------------------------------------------------------------------------------
23
+
24
+
25
+ def test_append_then_read_round_trips(tmp_path: Path) -> None:
26
+ p = _ledger(tmp_path)
27
+ append(p, {"event": "created", "who": "alice"})
28
+ append(p, {"event": "approved", "who": "bob"})
29
+ entries = read(p)
30
+ assert [e.payload["event"] for e in entries] == ["created", "approved"]
31
+ assert [e.seq for e in entries] == [0, 1]
32
+
33
+
34
+ def test_the_chain_links_each_record_to_its_predecessor(tmp_path: Path) -> None:
35
+ p = _ledger(tmp_path)
36
+ a = append(p, {"n": 1})
37
+ b = append(p, {"n": 2})
38
+ assert a.prior_hash == GENESIS # nothing precedes the first entry
39
+ assert b.prior_hash == a.content_hash # chained
40
+ assert a.content_hash != b.content_hash
41
+
42
+
43
+ def test_empty_and_missing_stores_read_clean(tmp_path: Path) -> None:
44
+ assert read(_ledger(tmp_path)) == [] # missing file
45
+ p = _ledger(tmp_path)
46
+ p.write_text("\n \n", encoding="utf-8") # only blank lines
47
+ assert read(p) == []
48
+ assert verify(p) is True
49
+
50
+
51
+ def test_content_hash_is_canonical_and_order_independent() -> None:
52
+ assert content_hash({"a": 1, "b": 2}) == content_hash({"b": 2, "a": 1})
53
+
54
+
55
+ # --- refusal / hostile -------------------------------------------------------------------------
56
+
57
+
58
+ def test_a_non_dict_payload_fails_loud(tmp_path: Path) -> None:
59
+ with pytest.raises(HashChainError, match="must be a JSON object"):
60
+ append(_ledger(tmp_path), ["not", "a", "dict"]) # type: ignore[arg-type]
61
+
62
+
63
+ def test_a_non_serializable_payload_fails_loud(tmp_path: Path) -> None:
64
+ with pytest.raises(HashChainError, match="not JSON-serializable"):
65
+ append(_ledger(tmp_path), {"tags": {1, 2, 3}}) # a set is not JSON-serializable
66
+ assert read(_ledger(tmp_path)) == [] # the bad record never reached disk
67
+
68
+
69
+ def test_a_tampered_payload_is_detected_on_read(tmp_path: Path) -> None:
70
+ p = _ledger(tmp_path)
71
+ append(p, {"amount": 100})
72
+ p.write_text(p.read_text().replace("100", "999"), encoding="utf-8") # edit without rehashing
73
+ with pytest.raises(HashChainError, match="tampered"):
74
+ read(p)
75
+ assert verify(p) is False
76
+
77
+
78
+ def test_a_reordered_chain_is_detected(tmp_path: Path) -> None:
79
+ p = _ledger(tmp_path)
80
+ append(p, {"n": 1})
81
+ append(p, {"n": 2})
82
+ first, second = p.read_text().splitlines()
83
+ p.write_text(second + "\n" + first + "\n", encoding="utf-8") # swap the two records
84
+ with pytest.raises(HashChainError, match="out of order|broken chain"):
85
+ read(p)
86
+
87
+
88
+ def test_deleting_a_middle_record_breaks_the_chain(tmp_path: Path) -> None:
89
+ p = _ledger(tmp_path)
90
+ append(p, {"n": 1})
91
+ append(p, {"n": 2})
92
+ append(p, {"n": 3})
93
+ lines = p.read_text().splitlines()
94
+ p.write_text(lines[0] + "\n" + lines[2] + "\n", encoding="utf-8") # drop the middle record
95
+ with pytest.raises(HashChainError, match="out of order|broken chain"):
96
+ read(p)
97
+
98
+
99
+ def test_a_malformed_line_fails_loud(tmp_path: Path) -> None:
100
+ p = _ledger(tmp_path)
101
+ p.write_text("{not valid json}\n", encoding="utf-8")
102
+ with pytest.raises(HashChainError, match="unreadable JSON"):
103
+ read(p)
104
+
105
+
106
+ def test_a_record_missing_a_field_fails_loud(tmp_path: Path) -> None:
107
+ p = _ledger(tmp_path)
108
+ p.write_text('{"seq": 0, "payload": {}}\n', encoding="utf-8") # no prior/content hash
109
+ with pytest.raises(HashChainError, match="malformed record"):
110
+ read(p)
111
+
112
+
113
+ def test_appending_to_a_tampered_ledger_fails_loud(tmp_path: Path) -> None:
114
+ """You cannot quietly extend a history that has already been broken."""
115
+ p = _ledger(tmp_path)
116
+ append(p, {"n": 1})
117
+ p.write_text(p.read_text().replace('"n": 1', '"n": 2'), encoding="utf-8")
118
+ with pytest.raises(HashChainError, match="tampered"):
119
+ append(p, {"n": 3})