Radoslaw B.

Sigillo: fitting an Ed25519-signed EU CAMF public alert into 87 bytes for LoRa packet radio

Radoslaw B. · 26 October 2026 · spec, conformance vectors and libraries

When power and mobile networks go down in an Alpine valley, low-power LoRa mesh radios keep working for days on a small battery. What they cannot carry is a 2 KB JSON alert wrapped in an X.509 certificate chain — and what a receiver should never trust is an unsigned text broadcast on a shared channel.

I published Sigillo (v1.0-rc) to solve that specific problem: a compact, cryptographically signed public-alert frame designed for low-bandwidth packet radio such as MeshCore. The base frame is 87 bytes — small enough to fit well inside MeshCore's 165-byte group payload limit and respect European 868 MHz duty-cycle constraints — yet every receiver can verify its authenticity, authority scope, and geographic target completely offline.

The repository ships the specification (spec/SPEC.md, CC BY 4.0), locked conformance test vectors (vectors/v1.json), and three bit-exact reference implementations under Apache-2.0: sigillo-c (C99), sigillo-py (Python 3.14), and sigillo-ts (TypeScript, Node 24).

Why 87 bytes? The airtime and payload budget

On sub-GHz LoRa (869.525 MHz in EU/CH under ERC/REC 70-03), every byte costs Time-on-Air (ToA), battery, and part of a strict 10% hourly duty cycle. In a multi-hop flood mesh, a packet that fragments across frames multiplies collision probability at every repeater hop. MeshCore's group data channel caps payloads at 165 bytes (MAX_FRAME = 165; Sigillo sets SENDER_LIMIT = 163).

An Asymmetric signature over Curve25519 (Ed25519, RFC 8032) already takes 64 bytes. That leaves roughly 23 to 100 bytes for everything else: wire framing, signer key identification, replay protection, hazard classification, severity, onset/duration, geographic target ellipse, and optional local extensions.

Instead of inventing a custom hazard taxonomy, Sigillo adopts the 122-bit Common Alert Message Format (CAMF, Issue 1.2, March 2026) developed for the EU Galileo Emergency Warning Satellite Service (EWSS), adds 6 Sigillo flag bits to align to 16 bytes (128 bits), and wraps it in a 7-byte header:

Offset Bytes Field Encoding
0 1 Header High nibble: wire version (0). Low nibble: frame kind (1 = CAMF, 2 = KEY, 3 = REVOKE, 4 = TIME, 5 = ROTATE).
1–2 2 Key ID 16-bit big-endian signer key identifier used to select candidate certificates on the receiver.
3–6 4 Issue time & sequence 24-bit unsigned minute since 2026-01-01T00:00Z (valid through November 2057) + 8-bit sequence number, packed as one 32-bit big-endian integer.
7–22 16 CAMF block 122 bits of EU/Japan CAMF (fields A1 → A18 packed MSB-first) + 6 Sigillo flag bits.
23 … n−65 0–78 TLV extensions Optional (type, len, value) records: Swiss BFS commune numbers (0x01), emergency meeting point PRU (0x02), reference frame ID (0x03), template (0x04), or explicit expiry (0x06).
n−64 … n−1 64 Signature Strict Ed25519 signature over the 9-byte ASCII domain separator SIGILLO/1 followed by bytes 0 … n−65.

With no TLV extensions, 7 + 16 + 64 = 87 bytes. Add three Swiss BFS commune codes (0x01) and the frame is 95 bytes; add a commune list, an emergency meeting point (PRU), and a Swiss template and it is still only 103 bytes.

What fits inside the 16-byte CAMF block

Every 128-bit CAMF block decodes deterministically without floating-point ambiguity or external lookup tables:

Finding 1: "Ed25519" does not mean the same thing across libraries

If a C99 firmware build on an ESP32-S3, a Python observer bridge on a Raspberry Pi, and a TypeScript web console in a browser inspect the same 87 bytes over radio, they must never disagree on whether a frame or certificate is valid.

During the design phase I tested standard cryptographic libraries against edge-case vectors, including the 12 test cases from Chalkias, Garillot, and Nikolaenko's paper "Taming the many EdDSAs" (ed25519-speccheck):

Sigillo's rule (Section 6): Every implementation must enforce five strict rules before accepting a signature or subject public key: canonical S < L, canonical point encodings for both A and R, rejection of all 8 small-order points for both A and R, and the unbatched cofactorless verification equation [S]B = R + [k]A. Under these rules, only case 3 of the 12 ed25519-speccheck vectors is valid — matching libsodium across C99, Python, and TypeScript.

To make sure duplicate suppression is immune even if someone wires a lenient verifier, Sigillo detects duplicates on (key_id, fingerprint, issue_minute, sequence) rather than raw packet bytes. A malleated copy of a valid frame can never trigger a second alarm.

Finding 2: Clockless microcontrollers and "silence is not safety"

Many battery-powered LoRa mesh nodes have no battery-backed real-time clock (RTC); after a reboot without GPS lock, a MeshCore node wakes up at a default 2024 date. If a protocol simply dropped frames when the local clock was wrong, a rebooted handheld in an emergency would go deaf. If it ignored time completely, it would be vulnerable to replay.

Sigillo separates authenticity from clock trust through three mechanisms:

  1. Per-key high-water marks in non-volatile flash: Even without a clock, the receiver tracks the highest (issue_minute, sequence) seen for each (key_id, fingerprint) and rejects replays older than REPLAY_WINDOW_MIN = 60 minutes below that high-water mark while tolerating out-of-order multi-hop flood delivery inside the window.
  2. VALID_AGE_UNKNOWN and 71-byte signed TIME beacons: When a receiver has no trusted clock, an authentic, in-scope alert is never silently dropped — it yields the normative status VALID_AGE_UNKNOWN and the UI must display an explicit "age unknown" badge. Certified time sources broadcast 71-byte signed TIME frames (kind 4) every 30 minutes that advance a clockless receiver's time monotonically forward.
  3. Mandatory "last heard" indicator: A jammed or disconnected radio looks identical to a quiet afternoon. Every Sigillo receiver persists the highest recorded issue minute across all frame kinds and surfaces "last heard HH:MM" in the UI. If beacons arrive every 30 minutes and last-heard age exceeds 60 minutes, the UI warns the operator that the node has lost the network.

Offline trust roots, revocation, and safety rails for testing

In an off-grid valley there is no OCSP responder or CRL server. Sigillo uses a two-tier offline trust chain fits into single LoRa frames:

Three reference libraries, one locked vector suite

A specification is only as good as its test suite. vectors/v1.json (rendered in human-readable form in vectors/v1.md) drives state-machine scenarios across all 17 normative receiver statuses (VALID, VALID_AGE_UNKNOWN, NOT_FOR_AREA, EXPIRED, DUPLICATE, BAD_SIGNATURE, UNKNOWN_KEY, NOT_SIGNED_BY_ROOT, REVOKED, REJECTED_KEY, NOT_ISSUER_SCOPE, NOT_TEST_UNDER_DEMO, FUTURE, TOO_OLD, MALFORMED, UNSUPPORTED_VERSION, UNKNOWN_KIND) plus the strict Ed25519 edge cases:

# C99 library (tested under both gcc and clang with ASan + UBSan)
make -C libs/sigillo-c test CC=gcc
make -C libs/sigillo-c test CC=clang

# Python 3.14 library
cd libs/sigillo-py && uv run python -m sigillo.conformance ../../vectors/v1.json

# TypeScript library (Node 24)
cd libs/sigillo-ts && npm run conformance -- ../../vectors/v1.json

The rule in the spec is simple: if a library disagrees with vectors/v1.json, the library is wrong — never the vectors.


The full specification, conformance vectors, and C99 / Python / TypeScript implementations are available at github.com/radek-sixi/sigillo. To see how Sigillo pairs with handheld LoRa hardware and SDR telemetry, read the companion write-up on Faro (sixi-tdeck-plus) and sixi-observer.