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]