Sigillo: fitting an Ed25519-signed EU CAMF public alert into 87 bytes for LoRa packet radio
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:
- Message type, country, provider, hazard, severity (A1–A5): 25 bits total. The 5-bit provider field (A3) MUST match the provider encoded in the signer's certificate, preventing an issuer certified for one agency from spoofing another.
- Integer onset and duration (A6–A8): 17 bits encoding current/next week, minutes since Monday
00:00 UTC, and duration class (<6h,6–12h,12–24h), resolved purely with integer arithmetic from the frame's 24-bit issue minute. - Geographic target ellipse (A12–A18): 33-bit linear grid centre (latitude and longitude), 5-bit logarithmic semi-major and semi-minor axes (
~0.216 kmto2500 km), 6-bit azimuth, and an optional 15-bit B1 refinement group. The specification locks the exact IEEE 754 double-precision flat-earth projection so that every receiver decidesNOT_FOR_AREAidentically.
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):
- A lenient
ref10-style check (inspecting only the top bits of scalarS) accepts a malleated signatureS + L. - OpenSSL accepts a trivial forgery when the public key is the identity point (
R = identity, S = 0, valid for any message), whereas libsodium rejects it. - Cofactored verification — such as the default ZIP-215 mode in
@noble/ed25519— acceptsR + T8(a signature whoseRpoint carries an order-8 torsion component) that strict cofactorless verifiers reject. - Browser WebCrypto Ed25519 behaves differently across browser engines on small-order and non-canonical points.
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:
- 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 thanREPLAY_WINDOW_MIN = 60minutes below that high-water mark while tolerating out-of-order multi-hop flood delivery inside the window. VALID_AGE_UNKNOWNand 71-byte signedTIMEbeacons: When a receiver has no trusted clock, an authentic, in-scope alert is never silently dropped — it yields the normative statusVALID_AGE_UNKNOWNand the UI must display an explicit "age unknown" badge. Certified time sources broadcast 71-byte signedTIMEframes (kind4) every 30 minutes that advance a clockless receiver's time monotonically forward.- 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:
- Primary + offline backup roots: Every receiver is provisioned with two roots (primary and an air-gapped backup). Roots sign short-lived 113-byte
KEYcertificates (kind2, optionally scoped to specific Swiss BFS communes via TLV0x11),REVOKEframes (kind3, 72 bytes + 6 bytes per revoked key), and 105-byteROTATEframes (kind5). - Immediate root compromise recovery: If a primary root is ever compromised, the backup root broadcasts a single 78-byte
REVOKEframe naming the primary's(key_id, fingerprint). Step 5 of the receiver pipeline immediately voids the primary root and every certificate it ever signed. - Strict
TESTandDEMOroot isolation: Every trust root carries a trust class (TEST,DEMO, orPRODUCTION), inherited by every certificate it signs. Under aTESTorDEMOroot, any CAMF frame whose message type (A1) is anything other than0 (Test)is rejected by the receiver asNOT_TEST_UNDER_DEMO, and accepted test frames must render with a visibleTESTbanner. Open-source builds ship with zero production roots.
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.