Skip to content

GPS: optional anti-spoofing guard for position and time (GPS_GUARD) - #4

Merged
fdlamotte merged 6 commits into
fdlamotte:gps_refactorfrom
AlexandrTyshchenko:feature/gps-guard-refactor
Nov 9, 2026
Merged

fdlamotte merged 6 commits into
fdlamotte:gps_refactorfrom
AlexandrTyshchenko:feature/gps-guard-refactor

Conversation

@AlexandrTyshchenko

@AlexandrTyshchenko AlexandrTyshchenko commented Nov 9, 2026 •

Copy link
Copy Markdown

@fdlamotte port of meshcore-dev#3736 onto gps_refactor, as discussed in meshcore-dev#3753. The guard now sits in the location provider: it decides what becomes _lastFix and whether GPS time may set the clock. The CLI is in CommonGpsPrefs.

Problem

A spoofed or jammed GPS signal can move a fixed node anywhere (position in adverts and telemetry) and set its clock to any time. The clock only moves forward, so a time pushed into the future breaks message timestamps, advert replay protection and logins until someone fixes it by hand.

Change

New header-only GpsGuard (src/helpers/sensors/GpsGuard.h), compiled only with GPS_GUARD.

Fixes. MicroNMEALocationProvider::saveFix() asks the guard before storing a fix. A fix becomes _lastFix only if it passes these checks:

  • at least GPS_GUARD_MIN_SATS satellites and HDOP ≤ GPS_GUARD_MAX_HDOP10/10;
  • inside the optional build-time region (GPS_GUARD_LAT_MIN/MAX, GPS_GUARD_LON_MIN/MAX);
  • within the fence (gps fence, default 1000 m) of the node's configured lat/lon, if they are set;
  • no jump faster than GPS_GUARD_MAX_SPEED_KMH from the last trusted fix;
  • GPS_GUARD_STABLE_FIXES consecutive consistent fixes, one per GPS second (GGA and RMC of the same second count once).

With the guard, the provider no longer falls back to the live parser position before the first stored fix. hasValidFix() means a trusted fix, so EnvironmentSensorManager and telemetry never see an unverified position.

Time. updateTime() asks the guard before setCurrentTime(). GPS time is accepted only under these conditions:

  • a trusted fix is current;
  • the time is not earlier than the firmware build date (and at most 10 years after);
  • once the clock holds a plausible time (the same window), the step is at most GPS_GUARD_TIME_MAX_STEP (600 s) for the first sync;
  • after that, the step is within clock drift (GPS_GUARD_TIME_DRIFT_BASE + GPS_GUARD_TIME_DRIFT_PPM), so the clock cannot be walked away in small steps.

Setting the clock by hand (time, clock sync) resets that anchor. A clock outside the window (e.g. a bogus date in 2080 at boot) is corrected by the first trusted fix.

If a trusted fix keeps disagreeing with the clock by the same offset for GPS_GUARD_ALERT_MIN minutes, the guard raises an alert. Either the clock is wrong or the spoofing is sustained. The operator checks and confirms with gps guard accept. While the alert is active, the offset in hours is also reported in telemetry on the next free channel.

CLI (in CommonGpsPrefs, so repeater, room server, sensor and companion):

  • gps guard: status, e.g. > ok 7sat time ok +1s, > rej fence 12.3km time rej nofix, or ... ALERT gps-clk +3600s 75m.
  • gps guard on / gps guard off: turn the whole guard on or off, saved in the GPS prefs (guard, default on). While off, every fix and GPS time is accepted and gps guard shows off.
  • gps guard accept: apply the alerted GPS time.
  • gps fence [metres]: fence radius, 0 = off. Saved in the GPS prefs (fence, only with GPS_GUARD); unset means the build default GPS_GUARD_FENCE_M.
  • gps ref: the reference position. CommonCLI's GPSPrefs provides node_lat/node_lon, kept in sync on set lat, set lon and gps setloc. The companion has no reference, so its fence is inactive.

Other changes:

  • LocationProvider gets getGuard(), getClockOffset() and applyTimeNow(), which are no-ops by default.
  • RakLocationProvider delegates them to its NMEA provider on the serial path. The RAK12500 I2C path does not use the guard.
  • Docs: docs/cli_commands.md.

Without GPS_GUARD the behaviour is unchanged.

Testing

  • Builds with GPS_GUARD (and a region box): ProMicro_sensor, ProMicro_repeater, ProMicro_companion_radio_ble, heltec_tracker_v2_sensor, RAK_4631_sensor. Builds without it: ProMicro_repeater, RAK_4631_sensor.
  • Hardware: ProMicro nRF52840 (Faketec) + UART GPS, repeater build with GPS_GUARD, node location set via set lat/set lon, real fix about 0.3 km from it:
    • before the fix: gps guard -> no fix 0sat time -;
    • with the fix: ok 6sat time ok +3s, gps ref shows the node location, gps fence -> 1000;
    • gps fence 100: every fix is dropped (GPS fix rejected (fence) in the debug log), gps guard -> rej fence 0.3km time ok +3s;
    • gps fence 1000 again: settle 2/5, then ok 7sat.
  • Live spoofing on the same node (repeater build, region box lat 44.0–52.5 / lon 22.0–40.3): after about 10 minutes without a fix, the receiver reported a moving position about 12,000 km away (12 sats, HDOP 0.5–0.6, about 50 m/s). Every such fix was rejected (GPS fix rejected (region), some (hdop) when HDOP went up), one log line per GPS second. gps guard -> rej region time rej nofix: the position stayed at the last trusted fix and GPS time was not written to the clock. This continued across a reboot.
  • Changed after these tests (builds, not yet seen on hardware): gps guard no longer shows an old time ok while there is no current trusted fix; a clock outside the plausible window is corrected by GPS.
  • gps fence 500, reboot, gps fence -> 500 (then set back to 1000).
  • gps off, gps guard off, reboot: gps guard -> off; gps guard on -> no fix time -, then settle 2/5 after gps on.
  • Not tested on hardware: the time alert path (gps guard accept), RAK12500 over I2C.

A spoofed GPS signal can move a fixed node anywhere and set its clock to
any time; the clock only moves forward, so a time pushed into the future
breaks timestamps and replay protection until fixed by hand.

With GPS_GUARD, MicroNMEALocationProvider asks a GpsGuard before a fix is
stored as the last fix (satellites, HDOP, optional region box, fence
around the configured position, jump speed, N stable fixes) and before
GPS time is written to the clock (trusted fix current, not before the
build date, small steps once the clock is valid, drift-sized after the
first sync). Without a trusted fix no live position is reported.

CLI in CommonGpsPrefs: 'gps guard', 'gps guard accept', 'gps fence',
'gps ref'; CommonCLI keeps the reference on set lat/lon and gps setloc,
resets the time anchor on manual clock sets and adds 'time force'.

Ported from meshcore-dev#3736 onto the GpsDriver/LocationProvider refactor.
Comment thread src/helpers/CommonCLI.cpp Outdated
uint32_t now = getRTCClock()->getCurrentTime();
DateTime dt = DateTime(now);
sprintf(reply, "%02d:%02d - %d/%d/%d UTC", dt.hour(), dt.minute(), dt.day(), dt.month(), dt.year());
} else if (memcmp(command, "time force ", 11) == 0) { // set time, allowing it to go backwards

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be on a separate PR, won't accept this in this context

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed time force from this PR (eea64ff), it will go to a separate PR against dev. The guard does not depend on it.

Also added a live test to the description: the node got a spoofed position about 12,000 km away for several minutes, the region check rejected every fix and GPS time was not written to the clock.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my reply above: instead of a new PR for time force, I added the use case to meshcore-dev#1349, which already allows setting the clock backwards.


// GPS time is only written to the clock while a trusted fix is current, it is not
// older than this firmware, and (once the clock is valid) it only nudges the clock.
bool acceptTime(uint32_t gps_time, uint32_t clock_time) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if the node has a totally bogus time at boot (and I had several nodes in that situation in the past, like dates in 2080), the gps can not correct their time if the guard is active ...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, fixed in fbd20ac: a clock outside the plausible window (build date to +10 years) is now treated like an unset one, so the first trusted fix corrects it. A clock inside the window but far off still goes through the alert and gps guard accept.

return true;
}
#ifdef GPS_GUARD
if (memcmp(command, "gps guard", 9) == 0 || memcmp(command, "gps fence", 9) == 0 ||

@fdlamotte fdlamotte Nov 9, 2026 •

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you don't seem to save the values of that commands ?

I say that because I see there is no modification in CommonCLI.h where markDirty command should be called to trigger saving of the values (for the companion, values are saved in NodePrefs.h)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, the fence was not saved. Fixed in d72f67d: gps fence <m> now goes through setGuardFence() → markDirty(), stored as fence in the GPS prefs (only with GPS_GUARD, -1 = build default), and applied at boot. Tested: gps fence 500, reboot, gps fence → 500. The other guard commands are status or one-shot actions (gps guard, gps guard accept, gps ref), nothing to save there.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good ...
was thinking
could the whole mechanism be enabled/disabled (with state saved) ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 8c7e1a8: gps guard on / gps guard off, saved in the GPS prefs (guard, default on). While off, every fix and GPS time passes and gps guard shows off; turning it back on starts clean (new stable fixes needed, no old alert). Tested: guard off, reboot → off, then on again.

@fdlamotte

Copy link
Copy Markdown
Owner

Thanks for the PR
This one is a little more touchy than the other, because it messes with time ;)
But as it is opt-in and configurable, it should be ok
Please provide saving for the prefs you've introduced, that's the main lack I see here ...

@AlexandrTyshchenko

Copy link
Copy Markdown
Author

Thanks! Saving is in d72f67d: gps fence is stored in the GPS prefs (fence) and applied at boot. That is the only runtime setting the guard adds: the region box is build-time, and the fence reference is the node's lat/lon, which are already saved.

@fdlamotte
fdlamotte merged commit 544514f into fdlamotte:gps_refactor Nov 9, 2026
1 check passed
@fdlamotte

Copy link
Copy Markdown
Owner

merged !
Thank you

@AlexandrTyshchenko

Copy link
Copy Markdown
Author

Thank you for the quick and careful review! The time window, saved prefs and on/off switch made the guard much better. Great working with you on this, and happy to help test the refactor before it goes to dev.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants