Signing and Verifying Transformation Audit Trails
A hash proves that a deliverable has not changed; a signature proves who produced it. This guide, part of compliance report generation for agency submission, covers the step after the audit digest: binding the digest to an identity so that a recipient can verify both the integrity and the origin of a transformation record, and doing it in a way that still verifies in five years.
What a Signature Adds
The digest described in generating audit hashes for transformation batches answers one question: are these the coordinates that were produced by that run? It cannot answer two others that matter in a dispute — who produced them, and can that person’s claim be checked without trusting the file that makes it.
Figure — three layers: canonical coordinates, an interpretive record, a signature.
A signature over the digest answers both, provided three things are true. The signed payload must be canonical, so that the same record always signs to the same bytes. The signature must cover the interpretive metadata as well as the coordinates, or a signed file can be re-labelled with a different CRS and still verify. And the verification key must be distributable independently of the deliverable, because a signature verified with a key that arrived in the same package proves nothing.
Complete Runnable Implementation
from __future__ import annotations
import hashlib
import hmac
import json
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
@dataclass(frozen=True)
class AuditRecord:
"""The interpretive record a signature has to cover."""
batch_id: str
row_count: int
source_crs: str
target_crs: str
coordinate_epoch: float | None
operation_id: str
operation_accuracy_m: float
grid_checksums: dict[str, str]
coordinate_digest: str # over the canonicalised coordinates
rmse_m: float
max_residual_m: float
excluded_rows: list[str]
def canonical_bytes(self) -> bytes:
"""One record, one byte sequence — sorted keys, no whitespace, UTF-8.
Wall-clock time is deliberately absent: a record that embeds when it was
written cannot be re-derived from the same inputs, and a signature over it
proves only that the clock moved.
"""
return json.dumps(asdict(self), sort_keys=True,
separators=(",", ":")).encode("utf-8")
def digest(self) -> str:
return hashlib.sha256(self.canonical_bytes()).hexdigest()
@dataclass(frozen=True)
class Signature:
"""A detached signature over an audit record."""
algorithm: str
key_id: str
value: str
signed_at: str # provenance only; not part of the payload
@classmethod
def create(cls, record: AuditRecord, key: bytes, key_id: str) -> "Signature":
"""HMAC-SHA256 over the canonical record.
A shared-secret HMAC suits an internal chain of custody. Where a recipient
must verify without holding the secret, the same canonical bytes are what
an asymmetric signature would sign — the canonicalisation is the part that
has to be right either way.
"""
mac = hmac.new(key, record.canonical_bytes(), hashlib.sha256).hexdigest()
return cls("HMAC-SHA256", key_id, mac,
datetime.now(timezone.utc).isoformat(timespec="seconds"))
def verify(self, record: AuditRecord, key: bytes) -> bool:
"""Constant-time comparison; never `==` on a MAC."""
expected = hmac.new(key, record.canonical_bytes(), hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, self.value)
def verify_package(record: AuditRecord, signature: Signature, key: bytes,
coordinate_digest: str) -> None:
"""Check both halves: the signature over the record, and the record over the data."""
if not signature.verify(record, key):
raise ValueError("signature does not verify: the audit record was altered")
if record.coordinate_digest != coordinate_digest:
raise ValueError(
"the audit record is authentic but does not describe these coordinates"
)
Parameter Reference
| Name | Type | Note |
|---|---|---|
coordinate_digest |
str |
Over canonicalised coordinates, computed separately |
canonical_bytes() |
bytes |
Sorted keys, no whitespace; the only signable form |
key_id |
str |
Identifies which key to verify with, without revealing it |
signed_at |
str |
Provenance; deliberately outside the signed payload |
verify_package() |
— | Fails differently for the two failure modes |
Worked Example
record = AuditRecord(
batch_id="parcels_2026_08",
row_count=48_213,
source_crs="EPSG:4267",
target_crs="EPSG:6318",
coordinate_epoch=2010.00,
operation_id="EPSG:1478",
operation_accuracy_m=0.150,
grid_checksums={"us_noaa_conus.tif": "3f9a2c1b"},
coordinate_digest="8c1d5e...",
rmse_m=0.018,
max_residual_m=0.041,
excluded_rows=["CV-207 disturbed monument"],
)
key, key_id = b"a-shared-secret-from-the-key-store", "survey-office-2026"
sig = Signature.create(record, key, key_id)
print(sig.algorithm, sig.key_id, sig.value[:16])
verify_package(record, sig, key, coordinate_digest="8c1d5e...")
# HMAC-SHA256 survey-office-2026 6b0e2c94a1f7d3ab
Re-running the same batch tomorrow produces the same record, the same digest and the same signature value — which is what makes the signature a statement about the data rather than about the run.
Figure — what the signed payload must and must not include.
Validation Check
def assert_record_is_reproducible(record: AuditRecord, key: bytes, key_id: str) -> None:
"""Signing the same record twice must give the same value."""
a = Signature.create(record, key, key_id)
b = Signature.create(record, key, key_id)
assert a.value == b.value, (
"the signature is not reproducible; something time- or order-dependent "
"has leaked into the canonical payload"
)
Common Mistakes
Signing only the coordinates. A signature over the numbers alone lets the same file be re-labelled with a different CRS, epoch or operation and still verify — and the label is what makes the numbers mean anything. Sign the interpretive record, and let the record carry the coordinate digest.
A timestamp inside the signed payload. It makes the signature unreproducible, so the same batch never verifies twice and the signature degenerates into a record of when someone pressed the button. Keep the timestamp beside the signature, not inside it.
Comparing MACs with ==. String equality short-circuits, which leaks timing information. hmac.compare_digest costs nothing and removes the question; it is the kind of detail that is easy to get right at the point of writing and awkward to explain afterwards.
Key Management, Briefly
The cryptography in this guide is the easy part; the key handling is what determines whether the signature means anything in five years. Four practices carry most of the weight.
Figure — signing keys retire; verification keys have to outlive the deliverable.
Keys live in a key store, not in the repository. A key committed to source control is a key known to everyone who has ever cloned it, and rotating it invalidates every signature made with it. Reference keys by identifier, as the key_id field does, and resolve the identifier at run time.
Signing keys and verification keys have different lifetimes. A signing key can be retired the moment it is superseded; the corresponding verification key has to remain available for as long as any signed deliverable can be challenged. Retiring both together is the mistake that makes an archive unverifiable.
Rotation is planned, not reactive. Rotating a key on a schedule, with the new identifier recorded in the deliverable, is routine. Rotating one because it may have been exposed means every signature made with it is now questionable, and the only remedy is re-signing the affected deliverables — which is possible precisely because the records are reproducible.
The key identifier is part of the audit record. Without it, a verifier holding several keys has to try each one, and a failure is ambiguous between “wrong key” and “altered record”. The identifier costs a short string and removes the ambiguity entirely.
Frequently Asked Questions
Is an HMAC enough, or do I need public-key signatures?
An HMAC establishes a chain of custody between parties who already share a secret — an internal pipeline, or a producer and a recipient with an existing key exchange. Where a recipient must verify without being able to create signatures, an asymmetric signature is required, and the canonicalisation above is unchanged: it is the payload that matters, not the algorithm.
How long should a signature remain verifiable?
As long as the deliverable can be challenged, which in cadastral work is decades. That argues for a widely implemented algorithm, an explicitly recorded algorithm name so a future reader knows what to use, and a key-management practice that keeps verification keys long after signing keys are retired.
What should a recipient do when verification fails?
Distinguish the two failures, because they mean different things. A failed signature means the record was altered or the wrong key was used. A verified signature over a record whose coordinate digest does not match the data means the record is authentic but describes a different file — usually a packaging mistake rather than tampering. The verification function above reports them separately for exactly that reason.
Does signing replace the residual report?
No. The signature says the record is authentic and unaltered; the residual statistics say whether the work meets specification. A signed record of a failing survey is a signed record of a failing survey, and both statements belong in the submission, as RMSE to agency submission workflow in Python describes.
Should intermediate results be signed too?
Usually not — signing every stage produces a chain nobody verifies and a key that is used far more widely than it needs to be. Sign the deliverable, and let the audit record inside it name the operations, grids and epochs that produced it. The exception is a pipeline whose stages are run by different parties, where each hand-off is a change of custody and a signature at the boundary is exactly what a chain of custody means.
One last practical point: keep the verification procedure short enough that a recipient will actually run it. A signature nobody checks is documentation, not evidence, and a one-command verifier shipped with the package is what turns the second into the first.