use super::input;
use super::signature::PublicKey;
use super::tables::KeyType;
use super::verifier::{Config, Event, TagResult, TagStatus, Verifier};
use super::{OsnmaStatus, ADVISORY};
use serde::Serialize;
use std::collections::BTreeMap;
const USAGE: &str = "usage: kshana osnma verify <input> [options]
<input> I/NAV pages: the plain format (see docs/OSNMA.md) or the
CSV layout of the published test vectors
--format pages|vector-csv|ubx
input layout (default: by file content). ubx is a u-blox
byte stream with UBX-RXM-SFRBX messages (Galileo E1-B)
--start-gst SECONDS start time of a vector CSV in GST seconds
(default: read from a file name like 16_AUG_2023_GST_05_00_01.csv)
--merkle-root HEX trusted Merkle root (64 hex digits) for DSM-PKR checks
--public-key ID:TYPE:HEX trusted public key, TYPE p256 or p521, compressed point
--reference-time SECONDS present time in GST seconds from an independent source. A single
instant: sub-frames further from it than the tolerance are set
aside, so use it for live or just-recorded data
--max-time-error SECONDS largest distance of a sub-frame from the reference time, below
30 (default 29); needs --reference-time
--epochs also print every tag result
--json print JSON instead of text
Exit status: 0 done, 2 usage or input error, 3 a check on some satellite's data failed.";
struct Options {
input: String,
format: Option<String>,
start_gst: Option<u32>,
merkle_root: Option<[u8; 32]>,
keys: Vec<PublicKey>,
reference: Option<u32>,
max_err: Option<u32>,
epochs: bool,
json: bool,
}
fn parse_args(args: &[String]) -> Result<Options, String> {
let mut o = Options {
input: String::new(),
format: None,
start_gst: None,
merkle_root: None,
keys: Vec::new(),
reference: None,
max_err: None,
epochs: false,
json: false,
};
let mut it = args.iter();
while let Some(a) = it.next() {
let mut val = |name: &str| it.next().cloned().ok_or(format!("{name} needs a value"));
match a.as_str() {
"--format" => o.format = Some(val("--format")?),
"--start-gst" => o.start_gst = Some(num(&val("--start-gst")?)?),
"--reference-time" => o.reference = Some(num(&val("--reference-time")?)?),
"--max-time-error" => o.max_err = Some(num(&val("--max-time-error")?)?),
"--merkle-root" => {
let b = hex::decode(val("--merkle-root")?).map_err(|_| "bad Merkle root hex")?;
o.merkle_root = Some(b.try_into().map_err(|_| "Merkle root must be 32 bytes")?);
}
"--public-key" => o.keys.push(parse_key(&val("--public-key")?)?),
"--epochs" => o.epochs = true,
"--json" => o.json = true,
s if s.starts_with("--") => return Err(format!("unknown option {s}")),
s if o.input.is_empty() => o.input = s.to_string(),
s => return Err(format!("unexpected argument {s}")),
}
}
if o.input.is_empty() {
return Err("missing input file".into());
}
if o.max_err.is_some() && o.reference.is_none() {
return Err("--max-time-error has no effect without --reference-time".into());
}
let cfg = Config {
max_time_error_s: o.max_err,
..Config::default()
};
cfg.validate()?;
Ok(o)
}
fn num(s: &str) -> Result<u32, String> {
s.parse().map_err(|_| format!("not a number: {s}"))
}
fn parse_key(s: &str) -> Result<PublicKey, String> {
let p: Vec<&str> = s.split(':').collect();
let [id, ty, hx] = p[..] else {
return Err("public key must be ID:TYPE:HEX".into());
};
let key_type = match ty {
"p256" => KeyType::P256,
"p521" => KeyType::P521,
_ => return Err("public key type must be p256 or p521".into()),
};
Ok(PublicKey {
pkid: id.parse().map_err(|_| "bad public key id")?,
key_type,
bytes: hex::decode(hx).map_err(|_| "bad public key hex")?,
})
}
fn name<T: Serialize>(t: &T) -> String {
match serde_json::to_value(t) {
Ok(serde_json::Value::String(s)) => s,
Ok(serde_json::Value::Object(m)) => {
let state = m.get("state").and_then(|v| v.as_str()).unwrap_or("?");
match m.get("reason").and_then(|v| v.as_str()) {
Some(r) => format!("{state} ({r})"),
None => state.to_string(),
}
}
_ => String::new(),
}
}
fn status_word(s: OsnmaStatus) -> &'static str {
match s {
OsnmaStatus::Authenticated => "authenticated",
OsnmaStatus::Failed => "failed",
OsnmaStatus::Unavailable => "unavailable",
}
}
fn event_json(e: &Event) -> serde_json::Value {
use serde_json::json;
match e {
Event::PublicKeyVerified { pkid } => json!({"kind":"public_key_verified","pkid":pkid}),
Event::PublicKeyRejected(r) => {
json!({"kind":"public_key_rejected","reason":format!("{r:?}")})
}
Event::AlertMessage { verified } => json!({"kind":"alert_message","verified":verified}),
Event::KrootVerified {
cid,
pkid,
hash,
mac,
key_bits,
tag_bits,
maclt,
} => json!({"kind":"kroot_verified","cid":cid,"pkid":pkid,
"hash":format!("{hash:?}"),"mac":format!("{mac:?}"),
"key_bits":key_bits,"tag_bits":tag_bits,"maclt":maclt}),
Event::KeyStall {
reference,
latest_key,
} => json!({"kind":"key_stall","reference":reference,"latest_key":latest_key}),
Event::ChainRevoked { cid } => json!({"kind":"chain_revoked","cid":cid}),
Event::PublicKeyRevoked { pkid } => json!({"kind":"public_key_revoked","pkid":pkid}),
Event::KrootRejected(r) => json!({"kind":"kroot_rejected","reason":format!("{r:?}")}),
Event::KeyVerified { gst } => json!({"kind":"key_verified","gst":gst}),
Event::KeyRejected { gst, reason } => {
json!({"kind":"key_rejected","gst":gst,"reason":name(reason)})
}
Event::TimeRejected { gst_sf } => json!({"kind":"time_rejected","gst":gst_sf}),
Event::BadCrc { .. } => json!({"kind":"bad_crc"}),
Event::Tag(_) => json!(null),
}
}
fn grouped_events(events: &[Event]) -> Vec<serde_json::Value> {
let mut order: Vec<String> = Vec::new();
let mut seen: BTreeMap<String, (serde_json::Value, u32)> = BTreeMap::new();
for e in events {
let j = event_json(e);
let key = j.to_string();
if !seen.contains_key(&key) {
order.push(key.clone());
}
seen.entry(key).or_insert((j, 0)).1 += 1;
}
order
.into_iter()
.filter_map(|k| seen.remove(&k))
.map(|(mut j, n)| {
j["count"] = n.into();
j
})
.collect()
}
#[derive(Default, Serialize)]
struct Counts {
authenticated: u32,
failed: u32,
pending: u32,
discarded: u32,
}
pub struct VerifyParams<'a> {
pub label: &'a str,
pub format: Option<&'a str>,
pub start_gst: Option<u32>,
pub merkle_root: Option<[u8; 32]>,
pub keys: Vec<PublicKey>,
pub reference: Option<u32>,
pub max_err: Option<u32>,
}
struct Analysis {
pages: usize,
ubx_stats: Option<super::ubx::UbxStats>,
events: Vec<Event>,
tags: Vec<TagResult>,
sats: Vec<(String, OsnmaStatus)>,
overall: OsnmaStatus,
nma_status: u8,
alert: bool,
counts: BTreeMap<u8, Counts>,
last_ok: BTreeMap<u8, u32>,
}
impl Analysis {
fn any_failed(&self) -> bool {
self.sats.iter().any(|(_, s)| *s == OsnmaStatus::Failed)
}
}
fn analyse(raw: Vec<u8>, p: &VerifyParams) -> Result<Analysis, String> {
let layout = match p.format {
Some(f @ ("vector-csv" | "pages" | "ubx")) => f,
Some(f) => return Err(format!("unknown format {f}")),
None if raw.starts_with(&[0xB5, 0x62]) || std::str::from_utf8(&raw).is_err() => "ubx",
None if raw
.iter()
.skip_while(|b| b.is_ascii_whitespace())
.take(4)
.eq(b"SVID".iter()) =>
{
"vector-csv"
}
None => "pages",
};
let mut ubx_stats = None;
let pages = if layout == "ubx" {
let (pg, st) = super::ubx::pages_from_ubx(&raw);
ubx_stats = Some(st);
pg
} else {
let Ok(text) = String::from_utf8(raw) else {
return Err(format!(
"{} is not text; use --format ubx for a UBX stream",
p.label
));
};
if layout == "vector-csv" {
let Some(start) = p.start_gst.or_else(|| input::start_from_filename(p.label)) else {
return Err("a vector CSV needs --start-gst or a dated file name".into());
};
input::parse_vector_csv(&text, start)?
} else {
input::parse_pages(&text)?
}
};
if p.max_err.is_some() && p.reference.is_none() {
return Err("--max-time-error has no effect without --reference-time".into());
}
let mut v = Verifier::new(Config {
merkle_root: p.merkle_root,
public_keys: p.keys.clone(),
max_time_error_s: p.reference.map(|_| p.max_err.unwrap_or(29)),
..Config::default()
});
if let Some(r) = p.reference {
v.set_reference_time(r);
}
let mut events = Vec::new();
let mut tags: Vec<TagResult> = Vec::new();
for pg in &pages {
for e in v.push_page(pg) {
match e {
Event::Tag(t) => tags.push(t),
other => events.push(other),
}
}
}
let mut counts: BTreeMap<u8, Counts> = BTreeMap::new();
let mut last_ok: BTreeMap<u8, u32> = BTreeMap::new();
for t in &tags {
let c = counts.entry(t.prnd).or_default();
match t.status {
TagStatus::Authenticated => {
c.authenticated += 1;
let e = last_ok.entry(t.prnd).or_insert(0);
*e = (*e).max(t.tag_gst);
}
TagStatus::Failed(_) => c.failed += 1,
TagStatus::Pending(_) => c.pending += 1,
TagStatus::Discarded(_) => c.discarded += 1,
}
}
Ok(Analysis {
pages: pages.len(),
ubx_stats,
events,
tags,
sats: v.sat_status(),
overall: v.overall(),
nma_status: v.nma_status(),
alert: v.alert(),
counts,
last_ok,
})
}
fn report_json(a: &Analysis, p: &VerifyParams) -> serde_json::Value {
let sat_json: Vec<_> = a
.sats
.iter()
.map(|(s, st)| {
let prn: u8 = s[1..].parse().unwrap_or(0);
serde_json::json!({
"sat": s,
"status": status_word(*st),
"tags": a.counts.get(&prn),
"last_authenticated_tag_gst": a.last_ok.get(&prn),
})
})
.collect();
serde_json::json!({
"advisory": ADVISORY,
"input": {"path": p.label, "pages": a.pages},
"overall": status_word(a.overall),
"nma_status": a.nma_status,
"alert": a.alert,
"freshness_enforced": p.reference.is_some(),
"reference_time": p.reference,
"satellites": sat_json,
"events": grouped_events(&a.events),
"tags": a.tags,
"pksos": super::pksos_sentence(a.overall, &a.sats),
"ubx": a.ubx_stats.as_ref().map(|u| serde_json::json!({
"frames": u.frames, "skipped_bytes": u.skipped_bytes,
"inav_pages": u.inav_pages, "ignored_sfrbx": u.ignored_sfrbx,
"bad_crc": u.bad_crc, "unstamped": u.unstamped,
"time_conflicts": u.time_conflicts,
})),
})
}
pub fn verify_report(raw: Vec<u8>, p: &VerifyParams) -> Result<(serde_json::Value, bool), String> {
let a = analyse(raw, p)?;
Ok((report_json(&a, p), a.any_failed()))
}
pub fn parse_public_key(s: &str) -> Result<PublicKey, String> {
parse_key(s)
}
pub fn run(args: &[String]) -> i32 {
if matches!(args.first().map(String::as_str), Some("--help" | "-h")) {
println!("{ADVISORY}\n\n{USAGE}");
return 0;
}
if args.first().map(String::as_str) != Some("verify") {
eprintln!("{ADVISORY}\n\n{USAGE}");
return 2;
}
let o = match parse_args(&args[1..]) {
Ok(o) => o,
Err(e) => {
eprintln!("{ADVISORY}\n\nerror: {e}\n\n{USAGE}");
return 2;
}
};
let raw = match std::fs::read(&o.input) {
Ok(t) => t,
Err(e) => {
eprintln!("{ADVISORY}\n\nerror: cannot read {}: {e}", o.input);
return 2;
}
};
let params = VerifyParams {
label: &o.input,
format: o.format.as_deref(),
start_gst: o.start_gst,
merkle_root: o.merkle_root,
keys: o.keys.clone(),
reference: o.reference,
max_err: o.max_err,
};
let a = match analyse(raw, ¶ms) {
Ok(a) => a,
Err(e) => {
eprintln!("{ADVISORY}\n\nerror: {e}");
return 2;
}
};
if o.json {
match serde_json::to_string_pretty(&report_json(&a, ¶ms)) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("{ADVISORY}\n\nerror: {e}");
return 2;
}
}
} else {
print_text(&a, &o);
}
if a.any_failed() {
3
} else {
0
}
}
fn print_text(a: &Analysis, o: &Options) {
let Analysis {
pages,
ubx_stats,
events,
tags,
sats,
overall,
nma_status,
alert,
counts,
last_ok,
} = a;
let overall = *overall;
{
println!("Galileo OSNMA verification\n{ADVISORY}\n");
println!("input: {} ({} pages)", o.input, pages);
if let Some(u) = ubx_stats {
println!(
"ubx: {} frames; {} I/NAV pages decoded, {} placed in time ({} bad CRC, {} unplaceable, {} time conflicts)",
u.frames, u.inav_pages, pages, u.bad_crc, u.unstamped, u.time_conflicts
);
}
println!(
"overall: {} NMA status: {}",
status_word(overall),
nma_status
);
if *alert {
println!("ALERT: a verified OSNMA alert message was received. Stop using OSNMA data; nothing is reported as authenticated.");
}
match o.reference {
None => println!(
"freshness: NOT enforced. No reference time was given, so sub-frame times are taken from the input and a replay of old data would authenticate."
),
Some(r) => {
let set_aside = events
.iter()
.filter(|e| matches!(e, Event::TimeRejected { .. }))
.count();
println!(
"freshness: sub-frames within {} s of the reference time {} are used; {set_aside} were set aside. A reference time is a single instant.",
o.max_err.unwrap_or(29),
input::label(r)
);
}
}
let mut kinds: BTreeMap<String, u32> = BTreeMap::new();
for e in events {
let j = event_json(e);
*kinds
.entry(j["kind"].as_str().unwrap_or("?").to_string())
.or_default() += 1;
}
for (k, n) in &kinds {
println!(" {k}: {n}");
}
println!("\nsatellite status authenticated failed pending discarded last authenticated");
for (s, st) in sats {
let prn: u8 = s[1..].parse().unwrap_or(0);
let c = counts.get(&prn);
let last = last_ok
.get(&prn)
.map(|g| input::label(*g))
.unwrap_or_else(|| "-".into());
println!(
"{s:<10} {:<15} {:>13} {:>6} {:>7} {:>9} {last}",
status_word(*st),
c.map_or(0, |c| c.authenticated),
c.map_or(0, |c| c.failed),
c.map_or(0, |c| c.pending),
c.map_or(0, |c| c.discarded),
);
}
if o.epochs {
println!("\nepoch (tag sub-frame) tx data adkd ctr result");
for t in tags {
println!(
"{:<26} E{:02} E{:02} {:>4} {:>3} {}",
input::label(t.tag_gst),
t.prna,
t.prnd,
t.adkd,
t.ctr,
name(&t.status)
);
}
}
println!("\n{}", super::pksos_sentence(overall, sats));
}
}