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/manifest.py · raw

  1"""Build, load, and verify evidence manifests, and verify seal chains.
  2
  3A manifest is a JSON object describing a sealed directory: every file's SHA-256,
  4a Merkle ``root`` over them, provenance metadata, and a ``previous`` link to an
  5earlier manifest's id. Its own ``id`` is the SHA-256 of its canonical form
  6(excluding ``id`` and ``signature``), so the manifest is self-verifying and can
  7be chained.
  8"""
  9
 10from __future__ import annotations
 11
 12import fnmatch
 13import json
 14from dataclasses import dataclass, field
 15from datetime import datetime, timezone
 16from pathlib import Path
 17
 18from . import ALGORITHM, MANIFEST_VERSION, __version__
 19from .hashing import hash_bytes, hash_file, merkle_root
 20
 21# Keys excluded from the canonical bytes the id is computed over. The id cannot
 22# cover itself; a signature and a timestamp are both applied *to* the id
 23# afterwards, so they sit outside it and compose independently of each other.
 24_ID_EXCLUDED = ("id", "signature", "timestamp")
 25
 26
 27def iter_files(
 28    directory: Path, ignore: list[str] | None, exclude: set[str]
 29) -> list[tuple[str, Path]]:
 30    """Return ``(posix_relpath, abspath)`` for every file under *directory*.
 31
 32    Paths matching an *ignore* glob (against the relative path) or present in
 33    *exclude* are skipped. Results are sorted by relative path.
 34    """
 35    ignore = ignore or []
 36    out: list[tuple[str, Path]] = []
 37    for path in directory.rglob("*"):
 38        if not path.is_file():
 39            continue
 40        rel = path.relative_to(directory).as_posix()
 41        if rel in exclude or any(fnmatch.fnmatch(rel, pat) for pat in ignore):
 42            continue
 43        out.append((rel, path))
 44    out.sort(key=lambda pair: pair[0])
 45    return out
 46
 47
 48def canonical_bytes(manifest: dict, exclude: tuple[str, ...] = _ID_EXCLUDED) -> bytes:
 49    """Serialize a manifest deterministically for hashing or signing."""
 50    trimmed = {k: v for k, v in manifest.items() if k not in exclude}
 51    return json.dumps(trimmed, sort_keys=True, separators=(",", ":")).encode()
 52
 53
 54def compute_id(manifest: dict) -> str:
 55    """The manifest's self-id: SHA-256 of its canonical form."""
 56    return hash_bytes(canonical_bytes(manifest))
 57
 58
 59def build_manifest(
 60    directory: str | Path,
 61    metadata: dict | None = None,
 62    previous: str | None = None,
 63    ignore: list[str] | None = None,
 64    exclude: set[str] | None = None,
 65) -> dict:
 66    """Seal *directory* into a manifest dict (id included, unsigned)."""
 67    directory = Path(directory)
 68    if not directory.is_dir():
 69        raise FileNotFoundError(f"not a directory: {directory}")
 70
 71    files = iter_files(directory, ignore, exclude or set())
 72    entries = [
 73        {"path": rel, ALGORITHM: hash_file(abs_path), "bytes": abs_path.stat().st_size}
 74        for rel, abs_path in files
 75    ]
 76    hashes = {entry["path"]: entry[ALGORITHM] for entry in entries}
 77
 78    manifest = {
 79        "manifest_version": MANIFEST_VERSION,
 80        "algorithm": ALGORITHM,
 81        "tool": f"evidence-seal/{__version__}",
 82        "created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
 83        "subject": directory.name,
 84        "previous": previous,
 85        "metadata": dict(sorted((metadata or {}).items())),
 86        "ignore": sorted(ignore or []),
 87        "file_count": len(entries),
 88        "total_bytes": sum(entry["bytes"] for entry in entries),
 89        "root": merkle_root(hashes),
 90        "files": entries,
 91    }
 92    manifest["id"] = compute_id(manifest)
 93    return manifest
 94
 95
 96def write_manifest(manifest: dict, path: str | Path) -> None:
 97    """Write a manifest as pretty JSON (stable key order)."""
 98    Path(path).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
 99
100
101def load_manifest(path: str | Path) -> dict:
102    """Load a manifest JSON file."""
103    return json.loads(Path(path).read_text(encoding="utf-8"))
104
105
106@dataclass
107class VerifyResult:
108    """The outcome of verifying a directory against its manifest."""
109
110    modified: list[str] = field(default_factory=list)
111    added: list[str] = field(default_factory=list)
112    removed: list[str] = field(default_factory=list)
113    id_ok: bool = True
114    root_ok: bool = True
115    checked: int = 0
116
117    @property
118    def intact(self) -> bool:
119        """True only if nothing drifted and the manifest is internally sound."""
120        return (
121            not self.modified
122            and not self.added
123            and not self.removed
124            and self.id_ok
125            and self.root_ok
126        )
127
128
129def verify_manifest(
130    directory: str | Path,
131    manifest: dict,
132    ignore: list[str] | None = None,
133    exclude: set[str] | None = None,
134) -> VerifyResult:
135    """Compare the current contents of *directory* against *manifest*."""
136    directory = Path(directory)
137    recorded = {entry["path"]: entry[ALGORITHM] for entry in manifest.get("files", [])}
138
139    result = VerifyResult()
140    # The manifest is only trustworthy if its id and root re-derive.
141    result.id_ok = compute_id(manifest) == manifest.get("id")
142    result.root_ok = merkle_root(recorded) == manifest.get("root")
143
144    present: dict[str, str] = {}
145    for rel, abs_path in iter_files(directory, ignore, exclude or set()):
146        present[rel] = hash_file(abs_path)
147
148    result.checked = len(present)
149    for rel, digest in present.items():
150        if rel not in recorded:
151            result.added.append(rel)
152        elif recorded[rel] != digest:
153            result.modified.append(rel)
154    result.removed = [rel for rel in recorded if rel not in present]
155
156    for bucket in (result.modified, result.added, result.removed):
157        bucket.sort()
158    return result
159
160
161@dataclass
162class ChainResult:
163    """The outcome of verifying a sequence of chained manifests."""
164
165    ok: bool
166    length: int
167    broken_at: int | None = None  # index whose `previous` did not match
168    reason: str = ""
169
170
171def verify_chain(manifests: list[dict]) -> ChainResult:
172    """Verify manifests link oldest→newest via ``previous`` == prior ``id``.
173
174    Also checks that each manifest's own id re-derives, so a tampered link in
175    the middle is caught whether the break is in the pointer or the content.
176    """
177    if not manifests:
178        return ChainResult(ok=False, length=0, reason="no manifests given")
179
180    for index, manifest in enumerate(manifests):
181        if compute_id(manifest) != manifest.get("id"):
182            return ChainResult(False, len(manifests), index, "manifest id does not re-derive")
183        expected_prev = manifests[index - 1]["id"] if index else None
184        if manifest.get("previous") != expected_prev:
185            reason = (
186                "first manifest should have no previous"
187                if index == 0
188                else "previous does not match the prior manifest id"
189            )
190            return ChainResult(False, len(manifests), index, reason)
191
192    return ChainResult(ok=True, length=len(manifests))