Skip to content

fix: NTP Y2036 rollover - #40

Open
mattdibi wants to merge 1 commit into
cf-natali:masterfrom
eurotech:fix/2036_rollover
Open

fix: NTP Y2036 rollover#40
mattdibi wants to merge 1 commit into
cf-natali:masterfrom
eurotech:fix/2036_rollover

Conversation

@mattdibi

@mattdibi mattdibi commented May 27, 2026

Copy link
Copy Markdown

This PR fixes the NTP Y2036 rollover issue, interpreting NTP timestamp correctly after wrapping in 2036. The original fix was implemented in Eurotech’s fork here: eurotech#6, and we’d like to contribute it upstream.


The Problem

Contrary to what was said in #18 (comment) the ntplib library does support NTPv4 protocol correctly. As per RFC-5905 Section 7.3:

The header component is identical to the NTPv3 header and previous versions.

The 128 bit date format described in RFC-5905 Section 6 is never transmitted over the line.

For convenience in mapping between formats, the seconds field is divided into a 32-bit Era Number field and a 32-bit Era Offset field. Eras cannot be produced by NTP directly, nor is there need to do so. When necessary, they can be derived from external means, such as the filesystem or dedicated hardware.

In other words: up to NTP protocol version 4, the era number is not transmitted in the NTP network packets. So, assuming the NTP server sends the appropriate second counts before and after the rollover, it is is the task of the NTP client to determine and apply the correct era number to determine a correct date after 2036-02-07.

Sources:

Notable datapoints and glossary

As described in RFC-5905 Section 6, NTP timestamps sent over the line are 64 bits long:

The 64-bit timestamp format is used in packet headers and other places with limited word size. It includes a 32-bit unsigned seconds field spanning 136 years and a 32-bit fraction field resolving 232 picoseconds.

For the problem at hand we only care about the 32-bit integral part of the timestamps (i.e. the seconds).

The 136 years span (i.e. 2^32 seconds) is called NTP era. The NTP era spanning from 1/1/1900 (i.e. NTP prime epoch) to 7/2/2036 (i.e. the rollover date) is NTP era 0. This means that:

  • NTP Timestamp 0 of NTP era 0 is: 01/01/1900
  • NTP Timestamp 0x80000000 (i.e. 2^32/2) of NTP era 0 is: 20/01/1968
  • NTP Timestamp 0 of NTP era 1 is: 08/02/2036

Reference: https://www.eecis.udel.edu/~mills/y2k.html

The Solution

This PR patches the client (i.e. the ntplib library) so that it interprets correctly the timestamp sent by NTP server after the 07/02/2036 date.

The solution implemented in this PR simply assumes that, if we receive a timestamp from an NTP server which pre-dates the NTP era halfway point, we must have received a rolled-over timestamp and thus we must be in NTP era 1.

In other words: if we receive a timestamp representing a date before 21/01/1968, we arbitrarily decide to add 136 years (i.e. NTP era span) to the timestamp, thus obtaining dates past the Y2036 rollover date.

In other other words:

  • NTP Timestamps between [0x80000000, 0xFFFFFFFF] will be converted as-is to NTP Dates (i.e. NTP Era assumed 0)
  • NTP Timestamps between [0x00000000, 0x7FFFFFFF] will be assumed belonging to NTP Era one and therefore we will apply formula: NTP Date = NTP Timestamp + NTP Era.

This solution is similar to what was implemented in ntpd. See:

Limitations

ntplib library containing this patch will interpret correctly NTP timestamps between the dates:

  • 21/01/1968: i.e. Halfway through NTP era 0. NTP date: 2147483648 (0x80000000). NTP timestamp: 2147483648 (0x80000000)
  • 27/02/2104: i.e. Halfway through NTP era 1. NTP date: 6442450944 (0x180000000). NTP timestamp: 2147483648 (0x80000000)

NTP timestamps outside this range will be affected by the rollover effect and interpreted incorrectly.

  • Dates before 21/01/1968 will be rolled over forward (i.e. sent to 2036)
  • Dates after 27/02/2104 will be rolled over backward (i.e. sent to 1968)

This behaviour is consistent with the behaviour displayed by other tools (e.g. Wireshark, ntpd).


Testing

Setup

Raspberry Pi providing NTP server via Chrony

Configuration /etc/chrony/chrony.conf:

# Do not use any upstream NTP servers
# (remove/comment any existing server or pool lines)

# Serve this machine's own system clock as an NTP source
local stratum 10

# Allow clients from your network
allow 192.168.1.0/24

# Bind to the normal NTP port
port 123

# Optional logging / drift
driftfile /var/lib/chrony/drift
makestep 1.0 3
rtcsync
pi@raspberrypi:~ $ chronyc tracking
Reference ID    : 7F7F0101 ()
Stratum         : 10
Ref time (UTC)  : Wed Mar 25 08:53:15 2026
System time     : 0.000000000 seconds fast of NTP time
Last offset     : +0.000000000 seconds
RMS offset      : 0.000000000 seconds
Frequency       : 0.000 ppm slow
Residual freq   : +0.000 ppm
Skew            : 0.000 ppm
Root delay      : 0.000000000 seconds
Root dispersion : 0.000000000 seconds
Update interval : 0.0 seconds
Leap status     : Normal
pi@raspberrypi:~ $ chronyc sources
MS Name/IP address         Stratum Poll Reach LastRx Last sample               
===============================================================================

Script

import ntplib
from time import gmtime, strftime

c = ntplib.NTPClient()
response = c.request('192.168.1.51', version=4)

print("Response received from NTP server:")

print(f"Leap:          {ntplib.NTP.LEAP_TABLE[response.leap]}")
print(f"Version:       {response.version}")
print(f"Mode:          {ntplib.NTP.MODE_TABLE[response.mode]}")
print(f"Stratum:       {response.stratum}")
print(f"Precision:     {response.precision}")
print(f"Root Delay:    {response.root_delay}")
print(f"Root Dispersion: {response.root_dispersion}")
print(f"Reference ID: 0x{response.ref_id:x}")

utctime = strftime("%a %b %d %H:%M:%S %Y" ,gmtime(response.ref_time))
print(f"RF Time UTC:      {utctime} ({response.ref_timestamp})")
utctime = strftime("%a %b %d %H:%M:%S %Y" ,gmtime(response.orig_time))
print(f"OR Time UTC:      {utctime} ({response.orig_timestamp})")
utctime = strftime("%a %b %d %H:%M:%S %Y" ,gmtime(response.recv_time))
print(f"RX Time UTC:      {utctime} ({response.recv_timestamp})")
utctime = strftime("%a %b %d %H:%M:%S %Y" ,gmtime(response.tx_time))
print(f"TX Time UTC:      {utctime} ({response.tx_timestamp})")

Results

Screenshot 2026-03-30 at 17 44 38

This PR should address the following:

* fix: NTP Y2036 rollover

* style: fix pylint errors

* feat: add method assumptions

* test: test all range of possible values

* style: simplify chained comparison

* style: correct newline

* refactor: improve readability and add docs

* style: timestamp -> ntp_timestamp

* fix: system_to_ntp_time

* refactor: remove unused variables

* test: add assertion to highligth timestamp ambiguity

* docs: add note about ambiguity

* test: added boundary dates

* docs: some docs rewording

* docs: fix wrong doc string

* docs: remove wrong comment
@mattdibi mattdibi changed the title fix: NTP Y2036 rollover (#6) fix: NTP Y2036 rollover May 27, 2026
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.

1 participant