Clock synchronization reduces disagreement between machines’ civil-time readings. It supports certificate validity checks, timestamp interpretation, and many expiration policies, but it does not establish a perfect global clock. A host can emit well-formatted UTC while measuring the wrong instant. Applications still need to distinguish elapsed durations, absolute deadlines, and causal ordering rather than making them all depend on raw wall-clock comparisons.
Four timestamps estimate two quantities
An NTP exchange records client send time T1, server receive time T2, server transmit time T3, and client receive time T4. The usual offset estimate is ((T2-T1)+(T3-T4))/2; a positive result means the server appears ahead. The round-trip delay estimate is (T4-T1)-(T3-T2), subtracting time spent processing at the server. These are estimates from observations, not direct measurements of every one-way link delay.
If forward and reverse paths have unequal delay, the offset estimate inherits an error. A small round-trip measurement can be useful without proving symmetry. A synchronization daemon uses repeated samples, selection, filtering, and a clock model rather than treating one packet as unquestionable truth. Source reachability, uncertainty, and source trust all matter; a low stratum number alone is not an accuracy certificate.
Timestamp representation also differs from measurement accuracy. A fractional field can encode very fine increments while the underlying clock or network observation remains much less certain. Printing additional decimal places does not improve that evidence.
Clock control is another stage. A daemon can gradually slew corrections, and configured conditions can permit stepping the clock. Do not promise that NTP never produces a wall-clock discontinuity or always reaches sub-millisecond accuracy. The network, oscillator, platform, and configuration determine achievable behavior. Choose monitoring bounds from application needs and measured conditions instead of adopting one universal offset threshold.
When it breaks
A wall clock can move backward or forward, making naive elapsed-time subtraction negative or misleading. Use a suitable monotonic clock for a local duration or timeout. Its reference point is not a portable timestamp for another host or a later boot. Certificate dates and business deadlines still need civil-time semantics, so monotonic time does not replace all timestamp handling.
Malformed, unrelated, unsynchronized, or policy-denied NTP responses must not be accepted as ordinary samples. Matching a response’s origin timestamp to the request helps reject unrelated packets, but it is not cryptographic authentication. Production clients need the protocol’s fuller validation and source-selection behavior. Synchronization also does not turn a timestamp-based lease into a safe distributed lock or guarantee that close events sort in causal order.
Check it quickly
Use a fresh disposable Debian 12 Docker container with Python 3 and chrony installed; the package setup is apt-get update followed by apt-get install -y python3 chrony. Save the code as ntp-exchange.py and run it as root with python3 ntp-exchange.py. The recorded environment used chrony 4.3 and Python 3.11.2. Drop SYS_TIME capability and avoid host networking when creating the container.
from pathlib import Path
from tempfile import TemporaryDirectory
import json
import os
import socket
import struct
import subprocess
import time
assert os.geteuid() == 0 and Path('/.dockerenv').exists(), 'use a disposable root Linux container'
NTP_EPOCH = 2208988800
ERA = 2**32
def stamp(unix_time):
value = unix_time + NTP_EPOCH
whole = int(value)
return struct.pack('!II', whole % ERA, int((value - whole) * ERA))
def seconds(data, reference):
whole, fraction = struct.unpack('!II', data)
era = round((reference + NTP_EPOCH - whole) / ERA)
return whole + era * ERA - NTP_EPOCH + fraction / ERA
def estimate(t1, t2, t3, t4):
return ((t2 - t1) + (t3 - t4)) / 2, (t4 - t1) - (t3 - t2)
def validate(data, sent):
if len(data) < 48:
raise ValueError('short packet')
if data[0] >> 6 == 3:
raise ValueError('unsynchronised source')
if (data[0] & 7) != 4 or ((data[0] >> 3) & 7) != 4:
raise ValueError('unexpected mode or version')
if not 1 <= data[1] <= 15:
raise ValueError('unusable stratum')
if data[24:32] != sent:
raise ValueError('origin timestamp mismatch')
with TemporaryDirectory(prefix='ntp-exchange-') as directory:
root = Path(directory)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as reservation:
reservation.bind(('127.0.0.1', 0))
port = reservation.getsockname()[1]
config = root / 'chrony.conf'
config.write_text(f'pidfile {root}/pid\ncmdport 0\nbindcmdaddress {root}/command.sock\n'
f'port {port}\nbindaddress 127.0.0.1\nlocal stratum 8\nallow 127.0.0.1\n')
process = subprocess.Popen(['chronyd', '-x', '-n', '-u', 'root', '-f', str(config)],
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try:
deadline = time.monotonic() + 5
while not (root/'command.sock').exists():
assert process.poll() is None
if time.monotonic() > deadline:
raise AssertionError('chronyd did not start')
time.sleep(0.05)
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
client.settimeout(5)
client.connect(('127.0.0.1', port))
request = bytearray(48)
request[0] = 0x23
t1, start = time.time(), time.monotonic()
sent = stamp(t1)
request[40:48] = sent
client.send(request)
data = client.recv(512)
t4, finish = time.time(), time.monotonic()
validate(data, sent)
t2, t3 = seconds(data[32:40], t1), seconds(data[40:48], t1)
offset, delay = estimate(t1, t2, t3, t4)
assert data[1] == 8 and finish >= start
bad_origin = bytearray(data); bad_origin[24:32] = bytes(8)
alarm = bytearray(data); alarm[0] |= 0xC0
denied = bytearray(data); denied[1] = 0
rejected = []
for candidate in (data[:40], bad_origin, alarm, denied):
try:
validate(candidate, sent)
except ValueError as error:
rejected.append(str(error))
else:
raise AssertionError('invalid packet accepted')
symmetric = estimate(100, 105.020, 105.022, 100.042)
asymmetric = estimate(100, 105.010, 105.012, 100.042)
assert abs(symmetric[0] - 5) < 1e-9 and abs(symmetric[1] - 0.040) < 1e-9
assert abs(asymmetric[0] - 4.990) < 1e-9 and abs(asymmetric[1] - 0.040) < 1e-9
print(json.dumps({'actual_loopback_sample': {'stratum': data[1],
'offset_seconds': offset, 'delay_seconds': delay,
'monotonic_elapsed_seconds': finish-start},
'rejected_packets': rejected,
'synthetic_symmetric_estimate': list(symmetric),
'synthetic_asymmetric_estimate': list(asymmetric),
'known_synthetic_offset': 5,
'system_clock_adjustment_disabled': True,
'external_UTC_accuracy_tested': False}))
finally:
process.terminate()
process.communicate(timeout=5)
The fixture starts chronyd with -x, which disables system-clock adjustment, and a loopback-only source using local stratum 8. It sends a real version-four client packet and checks the returned mode, stratum, origin timestamp, and alarm state. Expect a stratum-eight sample with measured offset and delay values that vary between runs. Four altered or truncated packets are rejected. Both sockets and the daemon are closed afterward.
Two separate synthetic calculations hold the true server lead at five seconds. With twenty milliseconds in each direction, the estimate is five seconds and the round-trip network delay forty milliseconds. With ten milliseconds outbound and thirty inbound, the same forty-millisecond delay accompanies an estimated lead of 4.990 seconds. The ten-millisecond error demonstrates the symmetry assumption; those values are not measurements of the loopback path.
All assertions passed. The local source shares the test host’s clock and has no external UTC reference. This experiment neither disciplines that clock nor proves external accuracy, NTS authentication, or full NTP client conformance. Keep the distinction between a valid observed exchange, a bounded estimate, and trustworthy operational time visible in diagnostics.
Sources
- RFC 5905: Network Time Protocol Version 4, timestamp exchange, offset estimation and delay, checked 2026-09-13.
- chronyd 4.3 manual, clock control and the no-adjustment option, checked 2026-09-13.
- Python: time, wall-clock and monotonic-clock behavior, checked 2026-09-13.