Galileo OSNMA receiver-side verifier
A receiver-side verifier for Galileo Open Service Navigation Message Authentication (OSNMA), in pure Rust. It reads I/NAV pages and reports, per satellite and per epoch, whether the navigation data is cryptographically authenticated, and why a check failed.
Advisory. This is analysis and monitoring output and not type-approved navigation equipment. The authentication status reported here is not a navigation integrity service and must not be the sole basis for any navigation or safety decision.
NOTICE: intellectual property in the Galileo OSNMA interface#
Kshana grants no rights under the intellectual property of the European Union in the Galileo OSNMA Signal-in-Space Interface Control Document (the "ICD IPRs"). The ICD carries its own authorisation (its Annex E), which is non-exclusive and royalty-free and has its own terms, scope and limits. Anyone who uses, builds or distributes software based on this module relies on that authorisation directly and must read and accept its terms themselves. Kshana's licence (AGPL-3.0-only, or the commercial licence) covers Kshana's own code only.
This module implements the algorithms the documents below describe. The ICD text is not reproduced; numeric values (field layouts, enumerations, the MAC look-up table) are re-expressed in Kshana's own notation, with the source cited in the code. The ICD and the Receiver Guidelines are © European Union; the source is acknowledged here. Kshana is not developed by, used by, approved by or endorsed by the European Union, the European Commission, EUSPA or any other body.
Reference documents#
Published by the European GNSS Service Centre.
| Document | Version | Date |
|---|---|---|
| Galileo OSNMA Signal-in-Space ICD ("SIS ICD") | Issue 1.1 | October 2023 |
| Galileo OSNMA Receiver Guidelines | Issue 1.3 | January 2024 |
Section and table numbers cited in the code and here refer to those versions.
What is verified#
The chain of trust, from the root down to the data:
- Public key (ICD 3.2.2, 6.2). A DSM-PKR is reassembled from its blocks and its Merkle path is walked to a trusted Merkle root (SHA-256).
- KROOT (ICD 3.2.3, 6.3). A DSM-KROOT is reassembled; its ECDSA signature (P-256/SHA-256 or P-521/SHA-512) is checked against the public key it names, over the NMA header and the chain parameters.
- TESLA keys (ICD 5.5, 6.4). Each key in a MACK message is walked back to the KROOT or to an earlier verified key, with the chain's hash function.
- MACSEQ (ICD 4.1.2, 6.6). The flexible Tag-Info fields are checked against the MACSEQ with the key that opens that MACK.
- Tags (ICD 4.2, 6.7). Each tag is recomputed (HMAC-SHA-256 or CMAC-AES, truncated to the chain's tag length) over the satellite identities, the sub-frame time, the tag position, the NMA status and the navigation data it covers, and compared. ADKD 0 (ephemeris, clock and status), ADKD 4 (timing parameters) and ADKD 12 (slow MAC, key published ten sub-frames later) are handled, with cross-authentication of satellites that do not themselves carry OSNMA, and dummy tags (COP 0).
A tag that names a reserved value, or whose ADKD does not match the MAC look-up table
slot, is set aside (the Receiver Guidelines say to discard it) and reported as
discarded, not as a failure.
Pages are checked against their CRC (the Galileo CRC-24Q) before anything else; a page
that fails is dropped and reported (bad_crc).
Status values#
Per tag: authenticated, failed with a reason (tag_mismatch, macseq_mismatch,
key_chain_mismatch, key_conflict), pending with a reason (no_chain,
awaiting_key, no_nav_data, unknown_maclt, unsupported_function,
service_not_usable, chain_mismatch), or discarded. A dummy tag (COP 0) shows as
authenticated at tag level, since it proves the key, but it covers no navigation data
and never counts for a satellite.
Per satellite, in the form the receiver-trust monitor consumes:
Authenticatedonly while the newest verified ephemeris and clock data (ADKD 0 or- of that satellite is recent (within 600 s of the verifier's time by default) and
is still the data in use. The check is on the data, not the IODnav: the verifier keeps
the SHA-256 of the authenticated bit string (Word Types 1 to 5 as ADKD 0 covers them)
and of the newest such string the satellite sends, and they must be equal. A copied
IODnav over other words, or altered Word Type 5 bits (which carry no IODnav), reads
Unavailableuntil the new data authenticates. A sub-frame that carries ephemeris words but no complete set of Word Types 1 to 5 under one IODnav takes the status away in the same way (every genuine E1-B sub-frame carries all five); one with no ephemeris words at all says nothing. Tags of another satellite (cross authentication) count for the satellite whose data they cover.
- of that satellite is recent (within 600 s of the verifier's time by default) and
is still the data in use. The check is on the data, not the IODnav: the verifier keeps
the SHA-256 of the authenticated bit string (Word Types 1 to 5 as ADKD 0 covers them)
and of the newest such string the satellite sends, and they must be equal. A copied
IODnav over other words, or altered Word Type 5 bits (which carry no IODnav), reads
Failedwhen a check on the satellite's data failed within the failure memory (600 s by default of the verifier's time), even if later data verifies. A mismatch is charged to the satellite whose data the tag covers and to the satellite that transmitted the tag, since either could be at fault; a MACSEQ mismatch is charged to the transmitting satellite.Unavailableotherwise, and for every satellite after a verified alert message, with NMA status "don't use" or a reserved status, and until the first key has verified.
The overall value is Failed if any satellite failed, Authenticated if at least one
is authenticated and none failed, otherwise Unavailable.
$PKSOS,<A|F|N>[,<sat>:<A|F|N>...] is the matching sentence;
kshana::osnma::pksos_sentence builds it.
Time#
The key chain shows that a key belongs to a given sub-frame, not that the sub-frame is
the present one. The verifier therefore never takes "now" from an unverified page.
Its time is the later of the reference time given with set_reference_time
(--reference-time) and the sub-frame of the latest TESLA key that verified (a key
cannot be known before its slot, so it proves the time is no earlier). Failure
memory, the validity window of an authentication and the eviction of old data run on
that time. A sub-frame stamped more than three sub-frames past the latest verified key
is set aside unless its own key proves its time; key and alert messages in it are still
processed, since they carry their own proof (a signature or the Merkle root).
The clock stands still when OSNMA stops, since only keys move it. A live caller must
call set_reference_time with host time every sub-frame; authentication then expires on
its own, and poll_clock (also run with every sub-frame) returns a KeyStall event once
no key has verified for longer than the authentication window.
What this does not give is freshness. Without a reference time from a source an
attacker cannot move, a replay of old authentic data under old stamps authenticates
and the page stamps are only as good as their source: the CLI says so
(freshness: NOT enforced) whenever no reference time is given. With a reference time
the tolerance is below one sub-frame (default 29 s; larger values are refused). A
reference time is a single instant: sub-frames further from it than the tolerance are
set aside and counted, so it suits live or just-recorded data, not a long recording.
For a recording, leave the reference time out and read the result as "authentic, not
necessarily fresh". The Receiver Guidelines give the formal requirement.
The page stamps of a u-blox stream come from the unauthenticated data itself (see Input), so they provide no freshness protection either.
Chains, keys and alerts#
Verified chains are kept by chain id (CID); tags are checked with the chain the NMA
header names, and a header naming a chain that was not verified leaves the sub-frame's
tags unchecked (chain_mismatch). A second KROOT of a chain in force adds an anchor;
a KROOT of another chain is kept alongside, so announcing the old chain again after a
renewal does not undo the new one.
The NMA header is not authenticated, so what it announces counts only when confirmed:
when more than one satellite sends the same header in a sub-frame, or when a verified
KROOT's signed message carries it (the header is part of that message). The two are not
equal. Agreement of more than one satellite protects against a single-satellite anomaly,
not against a spoofer: one spoofer can fake two satellite ids. What that buys it is
denial of service, and nothing more since a confirmed "don't use" clears every
satellite's authenticated data: lifting it, forged or genuine, restores nothing until
new tags verify under genuine operational sub-frames. (Before that clearing, a forged
lift could bring back, for up to the authentication window, data that had already
authenticated.) The real confirmation is the
KROOT-signed header, which also lifts a "don't use" again when it carries an operational
status. A single
satellite's header changes nothing but its own sub-frame's tag checking. A KROOT whose
time of applicability is not later than that of the chain held for the same id (a replay
of an older one) is refused (stale); a KROOT of the same chain adds an anchor.
The confirmed header's status and CPKS value drive revocation. With NMA status "don't use", CPKS "chain revoked" drops the chain the header names and refuses its KROOT from then on; CPKS "public key revoked" drops the public key that signed that chain, and every chain signed with it. If the replacement chain for that id was adopted before the header was confirmed, however long before, the key revoked is the previous chain's, not the new one's; that memory is kept until the revocation is carried out or another procedure's header is confirmed, and a revocation header repeated every sub-frame is carried out once. The same CPKS values with status operational mark the transition to the replacement and revoke nothing. The header is not itself authenticated, so a forged revocation can only take authentication away, and it is final until the verifier is restarted. That finality covers the live key too: a forged public key revocation resets the memory of the superseded key and revokes the key in use, and only a restart undoes it.
A DSM-PKR with NPKT 4 is the alert message; it is verified through the Merkle tree like
a key, its random NPK field being part of the leaf. A verified alert clears all chains
and public keys, refuses every later KROOT and public key, and makes every satellite
Unavailable, as the Receiver Guidelines direct, until the verifier is restarted.
A DSM-KROOT's padding field is a hash of the signed message and the signature. It is checked before the signature, and it also says which NMA header the signed message was built with when the header changed while the message was being assembled.
What is not verified, or not exercised#
- Freshness. See Time above: it needs a reference time from outside.
- Renewal and revocation. Chains and keys are followed as the streams of the published vectors exercise them (renewal, end of chain, chain and key revocation, new public key, new Merkle tree, alert), not every case the ICD allows. A revocation announced by the unauthenticated header is taken at its word and is final for the session. A new Merkle tree is picked up only through the Merkle root the verifier is given.
- Merkle tree hash. DSM-PKR messages are checked with a SHA-256 tree. A SHA3-256 tree, which the ICD allows in future, is not supported. The chain hash function can be SHA-256 or SHA3-256.
- Key status lists. Revocation is followed through the NMA header and the messages in the stream; certificate revocation lists are not read.
- Authenticated data coverage. ADKD 4 with COP 1 sent in an odd sub-frame cannot authenticate (Word Type 10 may be sent one or two sub-frames earlier); this costs availability only, never correctness.
- Constant-time operation. Not provided and not needed: the verifier holds no secrets (public keys, published chain keys and received data only).
- Signal and data quality. Pages are CRC-checked and alert pages dropped, but nothing is known of the signal they arrived on. Reed-Solomon recovery of I/NAV words 1 to 4 is not used: data must be received directly.
- Not a position integrity service. Authentication says the data came from the system. It says nothing about the signal it arrived on or the quality of the fix.
Input#
- Plain page format. One page pair per line:
<svid> <gst_seconds> <60 hex digits>,#comments allowed.gst_secondsis GST in seconds (week * 604800 + time of week) at the start of the page; the hex is the 240 bits of the even and odd halves (tails included), most significant bit first. - Test-vector CSV. The layout of the published vectors (
SVID,NumNavBits,NavBitsHEX). The start time comes from--start-gstor from a dated file name. - u-blox UBX-RXM-SFRBX. A raw UBX byte stream (
--format ubx, detected automatically). Galileo E1-B I/NAV messages are rebuilt into page pairs (src/osnma/ubx.rs); other systems, E5b, alert pages and frames with a bad checksum are ignored. SFRBX carries no time, so each page is stamped from the time inside its own data (word types 5 and 0) and accepted only if consecutive time-bearing pages agree with the number of pages received between them; pages that cannot be placed are dropped, never guessed. That time comes from the data itself and gives no freshness protection (see Time above). Signal id 0 (E1-C) carries no data, so accepting it alongside E1-B is harmless. A stray sync pair with an absurd length does not hold the reader back. The framing code is a third copy of the UBX framing in the tree (src/realdata/ubx.rs, the receiver-trust ingest); it is kept apart so that receiver-trust behaviour is unchanged, and the three could share one reader later. The word layout of an SFRBX message is taken from public descriptions and checked on synthetic frames only, not on a live receiver.
Command line#
kshana osnma verify <input> [--format pages|vector-csv|ubx] [--start-gst SECONDS]
[--merkle-root HEX] [--public-key ID:TYPE:HEX]
[--reference-time SECONDS [--max-time-error SECONDS]] [--epochs] [--json]
Text output gives the status per satellite and, with --epochs, per tag and epoch;
--json gives the same with the tag list. Exit status 3 means a check on some
satellite's data failed, 2 means a usage or input error. The advisory above is printed
first on every path, errors and usage included.
From Python and WebAssembly#
kshana.osnma_verify(data, format="", start_gst=None, merkle_root_hex="", public_keys=None, reference_time=None, max_time_error=None) (Python) and osnma_verify(input_base64, format, start_gst, merkle_root_hex, public_keys, reference_time, max_time_error) (WebAssembly) run the
same verifier on bytes in memory and return the JSON kshana osnma verify --json prints,
advisory included. The MCP server does not carry it: the input is a long raw receiver stream
better served by the command line and Python, and an agent tool would add a large-input surface
for no benefit. The same advisory and the same limits apply; the status is not a navigation
integrity service.
Tests and test data#
Test data is synthetic and generated by Kshana's own code and tests. No ICD or
Guidelines vectors, worked examples or key material are stored in this repository.
The default tests (tests/osnma_synthetic.rs and the unit tests) build a chain, pages
and tags from the equations and check consistency and that deliberate corruption is
caught: a flipped navigation bit, a flipped key bit, replayed tags, wrong time stamps,
a time outside the reference, missing data, and the attacks the review found (a
spoofed far-future sub-frame that must not erase a failure, authentication that must
expire and follow the IODnav, an alert, NMA status "don't use", dummy tags, MACSEQ
mismatch, conflicting keys, chain revocation and renewal, a bad padding or signature),
with CMAC-AES, a SHA3-256 chain and a P-521 signature carried through the verifier.
Because that data comes from our own
reading of the ICD, it shows self-consistency, not conformance.
The primitives are checked against published known-answer tests, none taken from the ICD: RFC 4231 (HMAC-SHA-256), RFC 4493 (CMAC-AES-128), FIPS 197 (AES), FIPS 202 (SHA3-256) and RFC 6979 (ECDSA P-256). P-521 signatures are checked for encoding and hash selection with a throwaway key made in the test; the curve arithmetic is the library's.
Conformance is checked by tests/osnma_official_vectors.rs, which is opt-in: it reads a
copy of the official test-vector archive that you download yourself, from the
directory named by KSHANA_OSNMA_VECTORS, and does nothing when the variable is unset.
It stores and redistributes nothing, and default test runs use no network. It gives the
verifier only a Merkle root and the public keys of one tree, as a receiver would have,
so chains are established from the signed DSM-KROOT in the stream and the whole path
runs: Merkle, signature, key chain, MACSEQ and tags. It asserts, per vector set, a
floor on the authenticated tags, none failed, which chains and keys the stream
announces, authenticated tags of ADKD 0, 4 and 12, that authentication resumes after
chain end, chain revocation, key revocation and new public key, that the alert
message verifies and leaves nothing authenticated, and that every vector uses only
HMAC-SHA-256, a SHA-256 chain, 128-bit keys, 40-bit tags and look-up entries 33 and 34.
Other algorithm and parameter choices of the ICD are therefore covered only by the
known-answer and synthetic tests above.
Dependencies#
RustCrypto crates, pure Rust, MIT OR Apache-2.0, built without default features:
hmac, cmac, aes, sha3, p256 and p521 (the ecdsa feature, used for
verification only), alongside sha2 already in the tree. They build for
wasm32-unknown-unknown and pass cargo deny.