时钟同步用于减少机器之间的民用时间读数差异,支撑证书有效期检查、事件时间解释以及许多过期策略,但不会建立完美的全局时钟。一台主机能够输出格式正确的 UTC,同时仍然测量了错误瞬间。应用需要区分经过时长、绝对截止时间和因果顺序,不能让所有语义都依赖未经解释的钟表数值比较。

四个时间戳估算两个量

一次 NTP 交换包含客户端发送时间 T1、服务端接收时间 T2、服务端发送时间 T3 和客户端接收时间 T4。常用偏移估计为 ((T2-T1)+(T3-T4))/2,正值表示服务端看起来比客户端更快。往返延迟估计为 (T4-T1)-(T3-T2),其中减去了服务端处理所花的时间。它们是根据观测推算的量,并没有直接测出每一段单向网络路径的时延。

当去程和回程延迟不同,偏移估计就会包含误差。较小的往返延迟有参考价值,却不能证明两个方向完全对称。同步守护进程会使用多次采样、来源选择、过滤和时钟模型,而不是把单个报文当作无条件可信的真相。来源可达性、估计不确定性和来源可信度都需要考虑,较低的 stratum 数字本身并不是精度证书。

时间戳的表示分辨率也不同于测量准确度。小数字段即使能编码非常细的步长,实际时钟或网络观测仍可能带有明显更大的不确定性。多打印几位小数不会改善证据质量,解释结果时应同时说明测量方法和它能够支持的结论。

时钟控制是后续的另一阶段。守护进程可以逐渐调整,也可以在配置允许的条件下直接跳变。因此不能保证 NTP 永远不会造成实时时钟的不连续,更不能承诺所有环境都达到亚毫秒精度。网络、振荡器、平台和配置共同决定实际效果。告警阈值应来自应用需求和测量条件,而不是把某个固定偏差值无条件推广到所有系统。

坏掉的样子

实时时钟可能向前或向后调整,直接相减得到的时长就可能失真甚至变成负数。本机持续时间和超时应选择适当的单调时钟,但其参考起点不能直接当成其他主机或下次启动仍可使用的时间戳。证书日期和业务截止时间仍需要民用时间语义,因此单调时钟并不是替代所有日期处理的通用答案。

格式不完整、与请求不对应、来源未同步或被策略拒绝的 NTP 响应,都不能当成普通样本使用。核对返回的 origin 时间戳是否匹配本次请求,有助于拒绝无关报文,但并不构成密码学认证。生产客户端还需要协议规定的更多校验和来源选择行为。时钟同步也不能自动让基于时间戳的租约成为安全分布式锁,更不能保证非常接近的事件按时间排序就等于因果顺序。

快速验证

使用全新的临时 Debian 12 Docker 容器,先执行 apt-get update,再执行 apt-get install -y python3 chrony 准备依赖。将代码保存为 ntp-exchange.py,以 root 运行 python3 ntp-exchange.py。实测版本是 chrony 4.3 和 Python 3.11.2。创建容器时应去掉 SYS_TIME 能力,并保持独立网络命名空间,不使用宿主网络;实验只需要容器内的回环通信。

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)

程序用 -x 启动 chronyd,明确禁止调整系统时钟,再配置仅供本地使用的 local stratum 8 来源。它发送真实的第四版客户端报文,检查响应模式、层级、origin 时间戳及告警状态。预期获得层级为八的实际样本,偏移和延迟数值会随运行条件变化。随后四份被改动或截短的报文分别被拒绝,最终关闭套接字与守护进程,不留下后台服务。

另外两个合成计算案例把真实服务端超前量都设为五秒。去程与回程各二十毫秒时,估计结果是五秒,网络往返延迟四十毫秒。改为去程十毫秒、回程三十毫秒后,往返仍为四十毫秒,估计超前量却变成 4.990 秒。十毫秒误差来自路径对称性的假设;这些固定数值是计算案例,不能写成实际回环链路的测量结果。

全部断言已经执行通过。本地来源共享测试主机的时钟,并没有外部 UTC 参考。实验既没有校准系统时钟,也没有证明外部精度、NTS 认证或完整客户端符合性。诊断记录应始终区分已经观察到的有效交换、带不确定性的估计,以及真正足以支撑生产业务的可信时间。

延伸阅读

来源