Firmware release manifest format

Copy Markdown View Source

One signed release-manifest.json per GitHub release replaces per-asset .fw.sig signing. The device verifies the manifest signature, enforces a monotonic counter, then verifies the per-target sha256 of the one asset it downloads. Firmware bytes are never signed directly — the manifest pins them by hash.

This document is the wire contract implemented by NervesGithubUpdater.Manifest and NervesGithubUpdater.Signature.

Assets on each release

AssetContent
release-manifest.jsonUTF-8 JSON, schema below
release-manifest.sigraw 64-byte Ed25519 signature (binary, not base64)
<app>_<target>.fwfirmware images referenced by the manifest

<app>_<target>.fw is just a convention — the manifest's asset field is the literal filename the device looks for, so the host project can name its firmware assets however its release pipeline already does.

JSON schema

{
  "version": 1,
  "counter": 1789475200,
  "signed_at": "2026-07-15T12:00:00Z",
  "expires_at": null,
  "targets": {
    "rpi3": {
      "asset": "my_app_rpi3.fw",
      "sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
      "size": 47185920
    }
  },
  "deltas": {}
}
  • version (required, int) — manifest format version. This document describes version 1. Devices reject unknown versions.
  • counter (required, non-negative int) — strictly increases across releases; CI typically uses Unix epoch seconds at signing time. The device persists the last-accepted counter (fw_manifest_counter, via the host's :kv_get/:kv_put opts — typically backed by Nerves.Runtime.KV) after a successful flash and refuses any manifest with a lower counter (equal is allowed, so the current release can be reinstalled). A device with no stored counter accepts and sets it — first-contact trust, same model as the public key baked into the firmware.
  • signed_at (required, ISO 8601 UTC) — informational timestamp.
  • expires_at (optional, ISO 8601 UTC or null) — if present and the device has expiry enforcement enabled (enforce_expiry: true, default off), manifests past this instant are refused. Off by default because a dormant project must not brick its devices' update path.
  • targets (required, non-empty map) — keyed by Nerves target name (nerves_fw_platform). Each entry:
    • asset — exact release asset filename to download,
    • sha256 — lowercase hex SHA-256 of the asset bytes,
    • size — asset size in bytes.
  • deltas (reserved) — future delta-update descriptors. Version-1 devices ignore it.

Unknown top-level keys are ignored (forward compatibility within version 1).

Signature construction

The signed message is sha512(manifest_bytes) — the 64-byte SHA-512 digest of the manifest file exactly as shipped (byte-for-byte; no canonicalization, no trailing-newline trimming). Ed25519 is then applied to that digest as a plain message (PureEdDSA over 64 bytes).

Why the double hash: signing the digest keeps the signed message a constant 64 bytes regardless of manifest size — inside AWS KMS's 4,096-byte RAW cap and trivial for any HSM.

# Signer side — always the same first step:
sha512sum release-manifest.json | cut -d' ' -f1 | xxd -r -p > digest.bin
# Device (verifying) — NervesGithubUpdater.Signature.verify_manifest/3:
digest = :crypto.hash(:sha512, manifest_bytes)
:crypto.verify(:eddsa, :none, digest, signature, [public_key, :ed25519])

The signing and verifying constructions must match exactly: the signer performs pure Ed25519 over the digest we provide, so the device must verify over the same digest, not over the raw manifest bytes. Test your signing pipeline against real device verification (or the library's Signature test suite) before relying on it in production.

Signer backends

The scheme is deliberately signer-agnostic: anything that produces a raw 64-byte Ed25519 signature over digest.bin works. The device only ever sees the public key (32 raw bytes baked into the firmware) and the signature. Known-good recipes:

AWS KMS (an Ed25519 asymmetric CMK, e.g. via an OIDC role with kms:Sign):

aws kms sign --key-id "$KMS_KEY_ID" \
  --message fileb://digest.bin --message-type RAW \
  --signing-algorithm ED25519_SHA_512 \
  --output text --query Signature | base64 -d > release-manifest.sig

GCP Cloud KMS (EC_SIGN_ED25519 key, SOFTWARE or HSM protection; PureEdDSA takes raw input, so omit --digest-algorithm):

gcloud kms asymmetric-sign \
  --location "$LOC" --keyring "$RING" --key "$KEY" --version "$VER" \
  --input-file digest.bin --signature-file sig.b64
base64 -d sig.b64 > release-manifest.sig

Local key / hardware HSM (offline signing, YubiHSM 2, Nitrokey, or any PKCS#11 token with Ed25519 — sign on a workstation or self-hosted runner, then attach with gh release upload):

# Plain openssl with a PEM key:
openssl pkeyutl -sign -rawin -inkey ed25519_private.pem \
  -in digest.bin -out release-manifest.sig
# YubiHSM 2: yubihsm-shell -a sign-eddsa -A ed25519 ... < digest.bin

Local Ed25519 key in CI (signs in-CI from an Ed25519 PEM stored as a repo secret; lower security than KMS since the private key touches the runner, useful for test releases or bootstrapping before KMS is set up):

openssl genpkey -algorithm ed25519 -out fw_signing.pem       # private
openssl pkey -in fw_signing.pem -pubout -outform DER \
  | tail -c 32 > firmware_signing.pub                        # commit into firmware
openssl pkeyutl -sign -rawin -inkey fw_signing.pem \
  -in digest.bin -out release-manifest.sig                    # sign

Whichever backend signs, sanity-check release-manifest.sig is exactly 64 bytes before uploading it as a release asset. The raw 32-byte public key baked into the firmware (wherever the host project embeds it — e.g. a file under rootfs_overlay/) is what the device trusts, independent of which backend signs.

Device-side pipeline

  1. Fetch release-manifest.json + release-manifest.sig from the release. Missing manifest ⇒ error (when verification is required).
  2. Verify signature (sentinel all-zero public key ⇒ refuse, fails closed).
  3. Parse + validate schema; reject unknown version.
  4. Optional expiry check (policy-gated, default off).
  5. Counter check against the persisted counter anchor.
  6. Look up the device's target in targets; download that asset.
  7. Verify streamed sha256 (and size) before the atomic rename.
  8. Flash via fwup; on success persist the new counter, then reboot.

Any failure broadcasts {:fw_update_progress, %{phase: :error, ...}} and resets the updater to :idle.

Bootstrap / rollout order

Devices running pre-manifest firmware ignore unknown release assets (they match their expected .fw asset by name via :asset_matcher and, with verification off, install it unverified). A typical rollout:

  1. Ship manifest-aware firmware via the old unverified path once.
  2. Provision the real public key (replaces the all-zero sentinel) in the next firmware; flip verification_required to true.
  3. From then on, enforcement is live; the manifest and counter gate every install.