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.
- matrym_hashchain-0.1.0/LICENSE +21 -0
- matrym_hashchain-0.1.0/PKG-INFO +114 -0
- matrym_hashchain-0.1.0/README.md +83 -0
- matrym_hashchain-0.1.0/pyproject.toml +66 -0
- matrym_hashchain-0.1.0/setup.cfg +4 -0
- matrym_hashchain-0.1.0/src/hashchain/__init__.py +142 -0
- matrym_hashchain-0.1.0/src/hashchain/py.typed +0 -0
- matrym_hashchain-0.1.0/src/matrym_hashchain.egg-info/PKG-INFO +114 -0
- matrym_hashchain-0.1.0/src/matrym_hashchain.egg-info/SOURCES.txt +11 -0
- matrym_hashchain-0.1.0/src/matrym_hashchain.egg-info/dependency_links.txt +1 -0
- matrym_hashchain-0.1.0/src/matrym_hashchain.egg-info/requires.txt +5 -0
- matrym_hashchain-0.1.0/src/matrym_hashchain.egg-info/top_level.txt +1 -0
- matrym_hashchain-0.1.0/tests/test_hashchain.py +119 -0
|
@@ -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
|
+
[](https://github.com/MatrymLabs/matrym-hashchain/actions/workflows/ci.yml)
|
|
35
|
+

|
|
36
|
+

|
|
37
|
+

|
|
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
|
+
[](https://github.com/MatrymLabs/matrym-hashchain/actions/workflows/ci.yml)
|
|
4
|
+

|
|
5
|
+

|
|
6
|
+

|
|
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,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
|
+
[](https://github.com/MatrymLabs/matrym-hashchain/actions/workflows/ci.yml)
|
|
35
|
+

|
|
36
|
+

|
|
37
|
+

|
|
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hashchain
|
|
@@ -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})
|