audit-labs/evidence-seal
Tamper-evident seals and chain of custody for audit evidence.
clone: git clone https://gitbay.org/audit-labs/evidence-seal.git
v1.0.0: evidence_seal/hashing.py · raw
1"""File hashing and a Merkle root over a set of files.
2
3The Merkle root condenses a whole package into one hash. Two packages with the
4same root are byte-identical in content and layout; any change to any file — or
5to the set of files — changes the root.
6"""
7
8from __future__ import annotations
9
10import hashlib
11from pathlib import Path
12
13_CHUNK = 1 << 20 # 1 MiB streaming reads keep memory flat on large evidence
14
15
16def hash_file(path: str | Path) -> str:
17 """Return the SHA-256 hex digest of a file, read in streaming chunks."""
18 digest = hashlib.sha256()
19 with Path(path).open("rb") as handle:
20 for chunk in iter(lambda: handle.read(_CHUNK), b""):
21 digest.update(chunk)
22 return digest.hexdigest()
23
24
25def hash_bytes(data: bytes) -> str:
26 """Return the SHA-256 hex digest of a bytes object."""
27 return hashlib.sha256(data).hexdigest()
28
29
30def _leaf(path: str, file_hash: str) -> str:
31 # Bind the path into the leaf so renaming a file changes the root even when
32 # its contents are unchanged. A domain prefix separates leaves from nodes.
33 return hashlib.sha256(f"leaf:{path}\x00{file_hash}".encode()).hexdigest()
34
35
36def _pair(left: str, right: str) -> str:
37 return hashlib.sha256(f"node:{left}{right}".encode()).hexdigest()
38
39
40def merkle_root(entries: dict[str, str]) -> str:
41 """Compute a Merkle root from a ``{path: file_hash}`` mapping.
42
43 Leaves are ordered by path so the root is deterministic regardless of
44 filesystem iteration order. An empty package has a well-defined empty root.
45 """
46 if not entries:
47 return hashlib.sha256(b"empty").hexdigest()
48
49 level = [_leaf(path, file_hash) for path, file_hash in sorted(entries.items())]
50 while len(level) > 1:
51 if len(level) % 2:
52 level.append(level[-1]) # duplicate the last node on an odd level
53 level = [_pair(level[i], level[i + 1]) for i in range(0, len(level), 2)]
54 return level[0]