Signed tile and config bundles for field updates

An update artefact reaching a field gateway has crossed a cellular network, possibly a customer’s site network, and a filesystem that may be shared with other software. The only property that makes it safe to apply is a signature the device can check against a key it trusted before the transfer began. This guide defines a bundle format that carries that signature, a verification routine that checks everything before anything is applied, and a resumable transfer that survives the link this fleet actually has. It belongs to secure provisioning and OTA updates within Edge Operations & Observability.

The bundle format

A bundle is a manifest, a signature over that manifest, and one or more payload files. The manifest — not the payloads — is what gets signed, and it carries a digest of every payload. That indirection is what makes a 3 GB tile archive verifiable without a 3 GB signature operation, and what lets a transfer be resumed and re-verified piecewise.

bundle-2026-05-04-zones-v42/
  manifest.json          ← the signed document
  manifest.sig           ← detached Ed25519 signature over manifest.json
  payload/zones-v42.fgb  ← 2.4 MB
  payload/thresholds.json ← 6 KB

The manifest is deliberately boring:

{
  "schema": 1,
  "bundle_id": "zones-v42-2026-05-04",
  "created_utc": "2026-05-04T09:12:44Z",
  "expires_utc": "2026-08-04T00:00:00Z",
  "min_firmware": "2.6.0",
  "target": {"model": "gw-4", "region": "eu-west"},
  "artefacts": [
    {"path": "payload/zones-v42.fgb", "kind": "zone_layer",
     "bytes": 2517216, "sha256": "9f31…c2a7", "activates": "atomic_swap"},
    {"path": "payload/thresholds.json", "kind": "threshold_config",
     "bytes": 6144, "sha256": "41ba…8e10", "activates": "reload_signal"}
  ]
}

Five fields in that document do real work. expires_utc bounds how long a captured bundle can be replayed at a device. min_firmware stops a bundle from being applied to an image that predates the feature it configures. target prevents a bundle intended for one hardware model or region from being applied elsewhere — the mistake that no signature can catch. kind binds each payload to the subsystem allowed to consume it. And activates tells the device which application pattern the artefact needs, so the update path does not have to infer it from the file extension.

What the signature covers, and how the payloads inherit it The signature covers the manifest only. The manifest carries a SHA-256 digest of each payload, so verifying the manifest's signature and then each payload's digest transitively verifies every byte, without requiring a signature operation over gigabytes. A note shows that this lets a 3 gigabyte archive be transferred in resumable chunks and verified incrementally, and that a payload whose digest does not match is discarded without affecting the rest of the bundle. One signature, transitively covering every byte manifest.sig Ed25519, 64 bytes manifest.json digests, kinds, targets, expiry zones-v42.fgb sha256 9f31…c2a7 · 2.4 MB thresholds.json sha256 41ba…8e10 · 6 KB Signing a 64-byte manifest instead of a 3 GB archive keeps the signature operation constant-time regardless of payload size, lets the transfer resume chunk by chunk, and lets one bad payload be re-fetched without invalidating the rest of the bundle. The manifest is the security boundary. Every payload check reduces to comparing a digest against a value inside it.
The indirection is not a shortcut — it is what makes verification compatible with resumable transfers over a link that drops.

The verification and apply routine

# bundle_apply.py — verify a bundle completely, then apply it atomically.
# Nothing touches the live system until every check has passed.
# Runs on the maintenance task; never inside the ingestion path.
import hashlib
import json
import os
from datetime import datetime, timezone
from pathlib import Path

CHUNK = 1 << 16
MAX_CLOCK_SKEW_S = 86_400          # tolerate a day of drift on a device with no NTP


class BundleRejected(Exception):
    pass


def _digest(path: Path) -> str:
    h = hashlib.sha256()
    with open(path, "rb") as fh:
        for block in iter(lambda: fh.read(CHUNK), b""):
            h.update(block)
    return h.hexdigest()


def verify_bundle(root: Path, pubkey, device) -> dict:
    """Returns the parsed manifest, or raises. Order is cheapest-first so a
    malformed bundle is rejected before any hashing happens."""
    man_path, sig_path = root / "manifest.json", root / "manifest.sig"
    if not man_path.exists() or not sig_path.exists():
        raise BundleRejected("incomplete bundle")

    raw = man_path.read_bytes()
    try:
        pubkey.verify(sig_path.read_bytes(), raw)
    except Exception:
        raise BundleRejected("signature")           # nothing else is worth checking

    man = json.loads(raw)
    if man.get("schema") != 1:
        raise BundleRejected("schema")

    now = datetime.now(timezone.utc)
    expires = datetime.fromisoformat(man["expires_utc"])
    if (now - expires).total_seconds() > MAX_CLOCK_SKEW_S:
        raise BundleRejected("expired")             # replay of an old bundle

    if man["target"]["model"] != device.model:
        raise BundleRejected("wrong model")
    if man["target"]["region"] != device.region:
        raise BundleRejected("wrong region")
    if _version_tuple(device.firmware) < _version_tuple(man["min_firmware"]):
        raise BundleRejected("firmware too old")

    for art in man["artefacts"]:
        path = root / art["path"]
        if not path.exists() or path.stat().st_size != art["bytes"]:
            raise BundleRejected(f"missing or truncated: {art['path']}")
        if _digest(path) != art["sha256"]:
            raise BundleRejected(f"digest mismatch: {art['path']}")
        if art["kind"] not in device.accepted_kinds:
            raise BundleRejected(f"unhandled kind: {art['kind']}")
    return man


def apply_bundle(root: Path, man: dict, device) -> list[str]:
    """Apply every artefact, each by the pattern its manifest entry declares.
    Returns the list applied so the caller can report and, if needed, revert."""
    applied = []
    for art in man["artefacts"]:
        src = root / art["path"]
        dest = device.destination_for(art["kind"])
        if art["activates"] == "atomic_swap":
            tmp = dest.with_suffix(dest.suffix + ".new")
            os.replace(src, tmp)                    # same filesystem: no copy
            _fsync_path(tmp)
            os.replace(tmp, dest)
            _fsync_dir(dest.parent)
        elif art["activates"] == "reload_signal":
            versioned = dest.parent / f"{dest.stem}-{man['bundle_id']}{dest.suffix}"
            os.replace(src, versioned)
            _fsync_path(versioned)
            _fsync_dir(versioned.parent)
            device.request_reload(art["kind"], versioned)
        else:
            raise BundleRejected(f"unknown activation: {art['activates']}")
        applied.append(art["kind"])
    device.record_versions(man["bundle_id"], applied)
    return applied


def _fsync_path(p: Path):
    fd = os.open(p, os.O_RDONLY)
    try:
        os.fsync(fd)
    finally:
        os.close(fd)


def _fsync_dir(p: Path):
    fd = os.open(p, os.O_DIRECTORY)
    try:
        os.fsync(fd)
    finally:
        os.close(fd)

Constraint validation

Constraint Expected impact Mitigation built into the code
RAM A 3 GB payload cannot be hashed in memory Streaming digest at 64 KB per read; peak is one chunk
Link reliability A transfer that restarts never completes on a poor link Per-payload digests allow chunked, resumable transfer verified piecewise
Flash Two copies exist briefly during the swap os.replace on the same filesystem moves rather than copies; only the destination pair coexists
Power loss An interrupted apply must not leave a mixture Every activation is a rename plus a directory sync
Clock A device with no time source cannot check expiry strictly Bounded skew allowance, with the time-source health reported separately
Trust A hostile network sits between build and device Signature checked first, on-device, against a key from the firmware image

Gotchas and edge cases

  • Verify before you move, not after. It is tempting to stream a payload directly into its destination and check it afterwards. That leaves a window where the live path holds unverified data, and on a device that reboots during the window, the unverified file is what comes back.
  • os.replace across filesystems copies. If the staging directory and the destination are on different mounts, what looks like an atomic rename is a copy plus a delete, and the atomicity is gone. Stage on the same filesystem as the destination, always.
  • Expiry needs a clock, and gateways lose theirs. A device whose RTC battery has died reports 1970 and rejects every bundle as expired, or 2038 and accepts anything. Bound the skew, report the time source’s health, and treat a device with no trustworthy clock as one that needs a technician rather than one that should trust bundles freely.
  • A bundle can be valid and still wrong for this device. The target and min_firmware checks exist because signing infrastructure is fleet-wide and deployments are not. A correctly signed bundle for the wrong region is the most likely bad-apply scenario in a multi-region fleet.
  • Keep the previous artefact until the new one has proven itself. For a zone layer, that means one duty cycle of normal operation. Deleting the old version at apply time turns a bad zone update into a site visit.
Rejection reasons ordered by how cheap they are to detect Six rejection checks in the order the routine applies them. A missing file costs a stat call. A bad signature costs one verification over the manifest. An expired or wrong-target bundle costs a parse. A firmware mismatch costs a comparison. Only after all of those does the routine hash the payloads, which is the expensive step. Ordering this way means a malformed or hostile bundle is rejected in milliseconds rather than after hashing three gigabytes. Cheapest rejection first — a hostile bundle should never reach the hasher files presentmanifest signatureschema + expirytarget model + regionmin firmwarepayload digests 0.1 ms0.4 ms0.2 ms<0.1 ms<0.1 ms14 s / 2.4 GB The last row is the only expensive one, and by the time it runs the bundle has already proven it is meant for this device.
The ordering is a denial-of-service defence as much as an efficiency one: an attacker who can deliver files should not be able to make the device hash gigabytes on demand.

Reporting the outcome

Every apply attempt should produce a record — bundle id, outcome, and on failure the specific rejection reason — spooled like any other event. Fleet-wide, those records are the fastest available signal that a rollout has gone wrong: a rejection reason that suddenly appears across many devices identifies the problem far more precisely than an absence of successful applies. wrong region on 40 devices is a build-pipeline mistake; expired on 3 devices is three dead RTC batteries; signature on any device at all is worth investigating immediately.

The verification model above assumes the bundle arrived. Getting it there is the other half, and on a cellular link with a median uninterrupted window measured in tens of seconds it is the harder half.

Three properties make a transfer survivable. It must be resumable at byte granularity — a range request against the payload, with the offset persisted, so a drop at 82% costs nothing. It must be verifiable in pieces, which the per-payload digests already allow: a payload that fails its digest is re-fetched alone rather than invalidating the bundle. And it must be abandonable, with a bounded number of attempts and a spooled failure record, so a device on a hopeless link stops burning radio time on an update it will never complete.

The transfer also needs its own budget. A basemap update racing the telemetry drain for the same modem produces a device that is neither updated nor reporting. Give the transfer a bandwidth share and a time-of-day window, the same way the drain in store-and-forward buffering is budgeted, and let telemetry win any contention — a device that stops reporting to fetch a map has its priorities backwards.

Resumable transfer against restart-on-failure over a dropping link A 96 megabyte payload over a link whose uninterrupted windows average 40 seconds at 240 kilobits per second, or about 1.2 megabytes each. Restarting on failure never completes: each attempt reaches roughly 1.2 megabytes and starts over. Resuming by byte offset completes in about 80 windows spread over several days, with the offset persisted between them. A note records that the per-payload digest lets a corrupted range be re-fetched without discarding the rest. 96 MB over 40-second windows restart on drop every attempt reaches ≈1.2 MB and starts again — never completes resume by offset ≈80 windows, offset persisted between each A corrupted range fails its payload digest and is re-fetched alone; the rest of the bundle is unaffected. The vertical breaks are link drops. Nothing about them costs progress.
Without resumption the transfer is not slow — it is impossible, and no amount of retry policy changes that.

Keep the bundle format itself boring and versioned. The schema field exists so that a future format change can be introduced without every deployed device rejecting the new bundles outright — a device that understands schema 1 and receives schema 2 should reject it with a specific, countable reason rather than a parse error, so the fleet’s readiness for the new format is measurable before anyone depends on it.