Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save anthonyryan1/933d7a9ad46b1113ed596c476c4f17aa to your computer and use it in GitHub Desktop.

Select an option

Save anthonyryan1/933d7a9ad46b1113ed596c476c4f17aa to your computer and use it in GitHub Desktop.
diff --git a/man/org.freedesktop.timesync1.xml b/man/org.freedesktop.timesync1.xml
index 47d7fdc77d..61b0e164cf 100644
--- a/man/org.freedesktop.timesync1.xml
+++ b/man/org.freedesktop.timesync1.xml
@@ -48,6 +48,7 @@ node /org/freedesktop/timesync1 {
readonly as SystemNTPServers = ['...', ...];
readonly as RuntimeNTPServers = ['...', ...];
readonly as FallbackNTPServers = ['...', ...];
+ readonly as NTSKeyExchangeServers = ['...', ...];
@org.freedesktop.DBus.Property.EmitsChangedSignal("false")
readonly s ServerName = '...';
@org.freedesktop.DBus.Property.EmitsChangedSignal("false")
@@ -63,6 +64,8 @@ node /org/freedesktop/timesync1 {
readonly (uuuuittayttttbtt) NTPMessage = ...;
@org.freedesktop.DBus.Property.EmitsChangedSignal("false")
readonly x Frequency = ...;
+ @org.freedesktop.DBus.Property.EmitsChangedSignal("false")
+ readonly b SecureTime = ...;
};
interface org.freedesktop.DBus.Peer { ... };
interface org.freedesktop.DBus.Introspectable { ... };
@@ -80,6 +83,8 @@ node /org/freedesktop/timesync1 {
<!--property FallbackNTPServers is not documented!-->
+ <!--property NTSKeyExchangeServers is not documented!-->
+
<!--property ServerName is not documented!-->
<!--property ServerAddress is not documented!-->
@@ -96,6 +101,8 @@ node /org/freedesktop/timesync1 {
<!--property Frequency is not documented!-->
+ <!--property SecureTime is not documented!-->
+
<!--Autogenerated cross-references for systemd.directives, do not edit-->
<variablelist class="dbus-interface" generated="True" extra-ref="org.freedesktop.timesync1.Manager"/>
@@ -112,6 +119,8 @@ node /org/freedesktop/timesync1 {
<variablelist class="dbus-property" generated="True" extra-ref="FallbackNTPServers"/>
+ <variablelist class="dbus-property" generated="True" extra-ref="NTSKeyExchangeServers"/>
+
<variablelist class="dbus-property" generated="True" extra-ref="ServerName"/>
<variablelist class="dbus-property" generated="True" extra-ref="ServerAddress"/>
@@ -128,6 +137,8 @@ node /org/freedesktop/timesync1 {
<variablelist class="dbus-property" generated="True" extra-ref="Frequency"/>
+ <variablelist class="dbus-property" generated="True" extra-ref="SecureTime"/>
+
<!--End of Autogenerated section-->
<para>
@@ -153,6 +164,15 @@ $ gdbus introspect --system \
<xi:include href="org.freedesktop.locale1.xml" xpointer="versioning"/>
+ <refsect1>
+ <title>History</title>
+ <refsect2>
+ <title>The Manager Object</title>
+ <para><varname>NTSKeyExchangeServers</varname> was added in version 261.</para>
+ <para><varname>SecureTime</varname> was added in version 261.</para>
+ </refsect2>
+ </refsect1>
+
<refsect1>
<title>See Also</title>
<para><simplelist type="inline">
diff --git a/man/systemd-timesyncd.service.xml b/man/systemd-timesyncd.service.xml
index f13a991a9c..c887b34bd8 100644
--- a/man/systemd-timesyncd.service.xml
+++ b/man/systemd-timesyncd.service.xml
@@ -41,6 +41,9 @@
cases that require full NTP support (and where SNTP is not sufficient) are not covered by
<filename>systemd-timesyncd.service</filename>.</para>
+ <para>The <filename>systemd-timesyncd.service</filename> service supports the Network Time Security (NTS)
+ mechanism for NTP. This allows for authentication of the time source and protect against spoofing attacks.</para>
+
<para>The NTP servers contacted are determined from the global settings in
<citerefentry><refentrytitle>timesyncd.conf</refentrytitle><manvolnum>5</manvolnum></citerefentry>, the
per-link static settings in <filename>.network</filename> files, and the per-link dynamic settings
diff --git a/man/timesyncd.conf.xml b/man/timesyncd.conf.xml
index 922debfcfa..f82618e0f5 100644
--- a/man/timesyncd.conf.xml
+++ b/man/timesyncd.conf.xml
@@ -63,12 +63,25 @@
<xi:include href="version-info.xml" xpointer="v216"/></listitem>
</varlistentry>
+ <varlistentry>
+ <term><varname>NTS=</varname></term>
+ <listitem><para>A space-separated list of NTS (secure NTP) server host names or IP addresses.
+ <command>systemd-timesyncd</command> will contact all configured servers in turn, until one responds.
+ When the empty string is assigned, the list of NTS servers is reset, and
+ all prior assignments will have no effect. This setting defaults to an empty list.
+ If NTS servers have been configured, any other configured NTP servers (including per-interface NTP servers acquired
+ from <citerefentry><refentrytitle>systemd-networkd.service</refentrytitle><manvolnum>8</manvolnum></citerefentry>)
+ will be ignored, including <varname>FallbackNTP=</varname> servers.</para>
+
+ <xi:include href="version-info.xml" xpointer="v261"/></listitem>
+ </varlistentry>
+
<varlistentry>
<term><varname>FallbackNTP=</varname></term>
<listitem><para>A space-separated list of NTP server host names or IP addresses to be used as the
fallback NTP servers. Any per-interface NTP servers obtained from
<citerefentry><refentrytitle>systemd-networkd.service</refentrytitle><manvolnum>8</manvolnum></citerefentry>
- take precedence over this setting, as do any servers set via <varname>NTP=</varname> above. This
+ take precedence over this setting, as do any servers set via <varname>NTP=</varname> and <varname>NTS=</varname> above. This
setting is hence only relevant if no other NTP server information is known. When the empty string is
assigned, the list of NTP servers is reset, and all prior assignments will have no effect. If this
option is not given, a compiled-in list of NTP servers is used.</para>
@@ -133,6 +146,18 @@
<xi:include href="version-info.xml" xpointer="v250"/></listitem>
</varlistentry>
+ <varlistentry>
+ <term><varname>KeyExchangeTimeoutSec=</varname></term>
+ <listitem><para>The maximum amount of time to wait for establishing an initial secure connection with
+ an NTS server, which establishes the keys used to secure subsequent requests.</para>
+
+ <para>Takes a time interval value. The default unit is seconds, but other units may be specified, see
+ <citerefentry><refentrytitle>systemd.time</refentrytitle><manvolnum>7</manvolnum></citerefentry>.
+ Defaults to 32 seconds.</para>
+
+ <xi:include href="version-info.xml" xpointer="v261"/></listitem>
+ </varlistentry>
+
</variablelist>
</refsect1>
diff --git a/meson.build b/meson.build
index 3672005d75..ce18098900 100644
--- a/meson.build
+++ b/meson.build
@@ -1689,6 +1689,12 @@ conf.set10('ENABLE_SSH_USERDB_CONFIG', conf.get('ENABLE_USERDB') == 1 and sshdco
conf.set10('SYSTEMD_SLOW_TESTS_DEFAULT', want_slow_tests)
+have = get_option('timesync-nts').require(
+ conf.get('HAVE_OPENSSL') == 1 and
+ libopenssl.version().version_compare('>= 3.5'),
+ error_message : 'openssl version >= 3.5 required').allowed()
+conf.set10('ENABLE_TIMESYNC_NTS', have)
+
#####################################################################
pymod = import('python')
@@ -3237,6 +3243,12 @@ else
found += f'static-libudev(@static_libudev@)'
endif
+if conf.get('ENABLE_TIMESYNC_NTS') == 1
+ found += 'timesync-nts'
+else
+ missing += 'timesync-nts'
+endif
+
summary({
'enabled' : ', '.join(found),
'disabled' : ', '.join(missing)
diff --git a/meson_options.txt b/meson_options.txt
index c1af7ce237..f9e6eb76d6 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -453,6 +453,7 @@ option('gnutls', type : 'feature', deprecated : { 'true' : 'enabled', 'false' :
description : 'gnutls support')
option('openssl', type : 'feature', deprecated : { 'true' : 'enabled', 'false' : 'disabled' },
description : 'openssl support')
+option('timesync-nts', type : 'feature', description : 'network time security support')
option('p11kit', type : 'feature', deprecated : { 'true' : 'enabled', 'false' : 'disabled' },
description : 'p11kit support')
option('libfido2', type : 'feature', deprecated : { 'true' : 'enabled', 'false' : 'disabled' },
diff --git a/src/shared/openssl-util.h b/src/shared/openssl-util.h
index 218641e06f..015fe418d7 100644
--- a/src/shared/openssl-util.h
+++ b/src/shared/openssl-util.h
@@ -75,6 +75,7 @@ DEFINE_TRIVIAL_CLEANUP_FUNC_FULL(OSSL_STORE_INFO*, OSSL_STORE_INFO_free, NULL);
DEFINE_TRIVIAL_CLEANUP_FUNC_FULL(PKCS7*, PKCS7_free, NULL);
DEFINE_TRIVIAL_CLEANUP_FUNC_FULL(PKCS7_SIGNER_INFO*, PKCS7_SIGNER_INFO_free, NULL);
DEFINE_TRIVIAL_CLEANUP_FUNC_FULL(SSL*, SSL_free, NULL);
+DEFINE_TRIVIAL_CLEANUP_FUNC_FULL(SSL_CTX*, SSL_CTX_free, NULL);
DEFINE_TRIVIAL_CLEANUP_FUNC_FULL(X509*, X509_free, NULL);
DEFINE_TRIVIAL_CLEANUP_FUNC_FULL(X509_NAME*, X509_NAME_free, NULL);
diff --git a/src/timesync/fuzz-nts-extfields.c b/src/timesync/fuzz-nts-extfields.c
new file mode 100644
index 0000000000..303178ea10
--- /dev/null
+++ b/src/timesync/fuzz-nts-extfields.c
@@ -0,0 +1,45 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later
+ * Copyright © 2026 Trifecta Tech Foundation */
+
+#include <assert.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "fuzz.h"
+#include "nts.h"
+#include "nts_extfields.h"
+
+static void eat(const uint8_t *buf, size_t size) {
+ if (!buf)
+ return;
+
+ while (size--)
+ DO_NOT_OPTIMIZE(buf[size]);
+}
+
+/* this program does no sanity checking as it is meant for fuzzing only */
+int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
+ uint8_t buffer[1280];
+ int len = MIN(size, sizeof(buffer));
+
+ memcpy(buffer, data, len);
+ if (len < 48)
+ return 0;
+
+ struct NTS_Query nts = {
+ .cipher = *NTS_get_param(NTS_AEAD_AES_SIV_CMAC_256),
+ .c2s_key = (void*)"01234567890abcdef",
+ .s2c_key = (void*)"01234567890abcdef",
+ };
+
+ /* fuzz the NTS extension field parser */
+ struct NTS_Receipt rcpt = {};
+ if (NTS_parse_extension_fields(buffer, len, &nts, &rcpt)) {
+ FOREACH_ELEMENT(cookie, rcpt.new_cookie)
+ eat(cookie->data, cookie->length);
+
+ eat(*rcpt.identifier, 32);
+ }
+
+ return 0;
+}
diff --git a/src/timesync/fuzz-nts-extfields.dict b/src/timesync/fuzz-nts-extfields.dict
new file mode 100644
index 0000000000..e66773a9ee
Binary files /dev/null and b/src/timesync/fuzz-nts-extfields.dict differ
diff --git a/src/timesync/fuzz-nts-extfields.options b/src/timesync/fuzz-nts-extfields.options
new file mode 100644
index 0000000000..a0dca12d11
--- /dev/null
+++ b/src/timesync/fuzz-nts-extfields.options
@@ -0,0 +1,2 @@
+[libfuzzer]
+max_len = 1280
diff --git a/src/timesync/fuzz-nts.c b/src/timesync/fuzz-nts.c
new file mode 100644
index 0000000000..31dc28947a
--- /dev/null
+++ b/src/timesync/fuzz-nts.c
@@ -0,0 +1,36 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later
+ * Copyright © 2026 Trifecta Tech Foundation */
+
+#include <assert.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "fuzz.h"
+#include "nts.h"
+
+static void eat(const uint8_t *buf, size_t size) {
+ if (!buf)
+ return;
+
+ while (size--)
+ DO_NOT_OPTIMIZE(buf[size]);
+}
+
+/* this program does no sanity checking as it is meant for fuzzing only */
+int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
+ uint8_t buffer[1280];
+ int len = MIN(size, sizeof(buffer));
+
+ memcpy(buffer, data, len);
+ if (len < 48)
+ return 0;
+
+ /* fuzz the nts ke routines */
+ struct NTS_Agreement rec;
+ if (NTS_decode_response(buffer, len, &rec) == 0) {
+ FOREACH_ELEMENT(cookie, rec.cookie)
+ eat(cookie->data, cookie->length);
+ }
+
+ return 0;
+}
diff --git a/src/timesync/fuzz-nts.dict b/src/timesync/fuzz-nts.dict
new file mode 100644
index 0000000000..2fb687e372
Binary files /dev/null and b/src/timesync/fuzz-nts.dict differ
diff --git a/src/timesync/fuzz-nts.options b/src/timesync/fuzz-nts.options
new file mode 100644
index 0000000000..a0dca12d11
--- /dev/null
+++ b/src/timesync/fuzz-nts.options
@@ -0,0 +1,2 @@
+[libfuzzer]
+max_len = 1280
diff --git a/src/timesync/fuzz-nullcipher.c b/src/timesync/fuzz-nullcipher.c
new file mode 100644
index 0000000000..a31d2ded9d
--- /dev/null
+++ b/src/timesync/fuzz-nullcipher.c
@@ -0,0 +1,62 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later
+ * Copyright © 2026 Trifecta Tech Foundation */
+
+#include <string.h>
+
+#include "nts_crypto.h"
+#include "timesyncd-forward.h"
+
+/* Null cipher, to let the fuzzer also generate meaningful inputs for
+ * the encrypted extension fields */
+
+#define BLKSIZ 16
+
+int NTS_encrypt(uint8_t *ctxt,
+ int ctxt_len,
+ const uint8_t *ptxt,
+ int ptxt_len,
+ const AssociatedData *info,
+ const struct NTS_AEADParam *aead,
+ const uint8_t *key) {
+
+ /* avoid 'unused' warnings */
+ (void) info;
+ (void) aead;
+ (void) key;
+
+ assert(ctxt_len >= ptxt_len + BLKSIZ);
+
+ memset(ctxt, 0xEE, BLKSIZ);
+ memmove(ctxt+BLKSIZ, ptxt, ptxt_len);
+ return ptxt_len + BLKSIZ;
+}
+
+int NTS_decrypt(uint8_t *ptxt,
+ int ptxt_len,
+ const uint8_t *ctxt,
+ int ctxt_len,
+ const AssociatedData *info,
+ const struct NTS_AEADParam *aead,
+ const uint8_t *key) {
+
+ /* avoid 'unused' warnings */
+ (void) info;
+ (void) aead;
+ (void) key;
+ (void) ptxt_len;
+
+ if (ctxt_len < BLKSIZ)
+ return -1;
+
+ assert(ptxt_len >= ctxt_len - BLKSIZ);
+
+ memmove(ptxt, ctxt+16, ctxt_len - BLKSIZ);
+ return ctxt_len - BLKSIZ;
+}
+
+const struct NTS_AEADParam* NTS_get_param(NTS_AEADAlgorithmType id) {
+ static struct NTS_AEADParam param = {
+ NTS_AEAD_AES_SIV_CMAC_256, 256/8, BLKSIZ, BLKSIZ, true, false, "AES-128-SIV"
+ };
+ return id? &param : NULL;
+}
diff --git a/src/timesync/meson.build b/src/timesync/meson.build
index b307245772..9a3a4b80f0 100644
--- a/src/timesync/meson.build
+++ b/src/timesync/meson.build
@@ -14,6 +14,14 @@ timesyncd_extract_sources = files(
'timesyncd-server.c',
)
+nts_sources = files(
+ 'nts_crypto_openssl.c',
+ 'nts_packet.c',
+ 'nts_error.c',
+ 'nts_extfields.c',
+ 'nts_tls.c',
+)
+
timesyncd_gperf_c = custom_target(
input : 'timesyncd-gperf.gperf',
output : 'timesyncd-gperf.c',
@@ -29,30 +37,68 @@ else
libshared_static]
endif
+# Add OpenSSL to the list of dependencies if NTS is enabled
+if conf.get('ENABLE_TIMESYNC_NTS') == 1
+ crypto_deps = [libopenssl]
+ timesyncd_extract_sources += nts_sources
+else
+ crypto_deps = []
+endif
+
executables += [
libexec_template + {
'name' : 'systemd-timesyncd',
'sources' : timesyncd_sources,
'extract' : timesyncd_extract_sources,
+ 'c_args' : c_args,
'link_with' : timesyncd_link_with,
'dependencies' : [
libm,
threads,
+ crypto_deps,
],
},
libexec_template + {
'name' : 'systemd-time-wait-sync',
'sources' : files('wait-sync.c'),
'objects' : ['systemd-timesyncd'],
- 'dependencies' : libm,
+ 'link_with' : timesyncd_link_with,
+ 'dependencies' : [libm, crypto_deps],
},
test_template + {
'sources' : files('test-timesync.c'),
'objects' : ['systemd-timesyncd'],
- 'dependencies' : libm,
+ 'c_args' : c_args,
+ 'link_with' : timesyncd_link_with,
+ 'dependencies' : [libm, crypto_deps],
},
]
+if conf.get('ENABLE_TIMESYNC_NTS') == 1
+ executables += [
+ test_template + {
+ 'sources' : files('test-nts.c') + nts_sources,
+ 'link_with' : timesyncd_link_with,
+ 'dependencies' : crypto_deps,
+ },
+ fuzz_template + {
+ 'sources' : files('fuzz-nts.c', 'fuzz-nullcipher.c', 'nts_packet.c'),
+ 'link_with' : libshared,
+ 'dependencies' : crypto_deps,
+ },
+ fuzz_template + {
+ 'sources' : files('fuzz-nts-extfields.c', 'fuzz-nullcipher.c', 'nts_extfields.c'),
+ 'link_with' : libshared,
+ 'dependencies' : crypto_deps,
+ },
+ test_template + {
+ 'sources' : files('test-nts-mockserver.c') + nts_sources,
+ 'dependencies' : crypto_deps,
+ 'type' : 'manual',
+ },
+ ]
+endif
+
custom_target(
input : 'timesyncd.conf.in',
output : 'timesyncd.conf',
diff --git a/src/timesync/nts.h b/src/timesync/nts.h
new file mode 100644
index 0000000000..509f50f30a
--- /dev/null
+++ b/src/timesync/nts.h
@@ -0,0 +1,144 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later
+ * Copyright © 2026 Trifecta Tech Foundation */
+#pragma once
+
+#include <errno.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <sys/types.h>
+
+/* algorithm type is not made into a full enum since it eases ptr-conversions */
+typedef uint16_t NTS_AEADAlgorithmType;
+enum {
+ NTS_AEAD_AES_SIV_CMAC_256 = 15,
+ NTS_AEAD_AES_SIV_CMAC_384 = 16,
+ NTS_AEAD_AES_SIV_CMAC_512 = 17,
+ NTS_AEAD_AES_128_GCM_SIV = 30,
+ NTS_AEAD_AES_256_GCM_SIV = 31,
+ _NTS_AEAD_MAX,
+ _NTS_AEAD_INVALID = -EINVAL,
+};
+
+typedef struct NTS_AEADParam {
+ NTS_AEADAlgorithmType aead_id;
+ uint8_t key_size, block_size, nonce_size;
+ bool tag_first, nonce_is_iv;
+ const char *cipher_name;
+} NTS_AEADParam;
+
+typedef enum NTS_ErrorType {
+ NTS_SERVER_UNKNOWN_CRIT_RECORD = 0,
+ NTS_SERVER_BAD_REQUEST = 1,
+ NTS_SERVER_INTERNAL_ERROR = 2,
+
+ NTS_UNEXPECTED_WARNING = 0x10000,
+ NTS_BAD_RESPONSE = 0x10001,
+ NTS_INTERNAL_CLIENT_ERROR = 0x10002,
+ NTS_NO_PROTOCOL = 0x10003,
+ NTS_NO_AEAD = 0x10004,
+ NTS_INSUFFICIENT_DATA = 0x10005,
+ NTS_UNKNOWN_CRIT_RECORD = 0x10006,
+
+ NTS_SUCCESS = 0x20000,
+
+ _NTS_ERROR_MAX,
+ _NTS_ERROR_INVALID = -EINVAL,
+} NTS_ErrorType;
+
+typedef struct NTS_Cookie {
+ uint8_t *data;
+ size_t length;
+} NTS_Cookie;
+
+typedef struct NTS_Agreement {
+ enum NTS_ErrorType error;
+
+ NTS_AEADAlgorithmType aead_id;
+
+ const char *ntp_server;
+ uint16_t ntp_port;
+
+ struct NTS_Cookie cookie[8];
+} NTS_Agreement;
+
+/* Encode a NTS KE request in the buffer of the provided size. If the third argument is not NULL,
+ * it must point to a NULL-terminated array of AEAD_algorithm-types that indicate the preferred AEAD
+ * algorithms (otherwise a sane default it used).
+ *
+ * RETURNS
+ * non-zero number of bytes encoded upon success
+ * negative value upon failure (not enough room in buffer)
+ */
+int NTS_encode_request(uint8_t *buffer, size_t buf_size, const NTS_AEADAlgorithmType *preferred_crypto);
+
+/* Decode a NTS KE reponse in the buffer of the provided size, and write the result to the NTS_Agreement
+ * struct. This function does not allocate data: pointers in the struct for a potential negotiated server
+ * name and NTS cookies point into buffer, and must be copied if buffer is deallocated or overwritten.
+ *
+ * RETURNS
+ * 0 upon success
+ * negative upon failure (writes the error code to NTS_Agreement->error)
+ */
+int NTS_decode_response(uint8_t *buffer, size_t buf_size, struct NTS_Agreement *response);
+
+/* Convert a NTS_ErrorType to a string */
+const char *NTS_error_string(enum NTS_ErrorType error);
+
+/* The following three functions provide runtime information about the chosen AEAD algorithm:
+ * - key size requirement in bytes
+ * - OpenSSL name of the AEAD algorithm
+ * - Fetched EVP_CIPHER for the AEAD algorithm (when SIV is provided by OpenSSL only)
+ */
+
+const struct NTS_AEADParam* NTS_get_param(NTS_AEADAlgorithmType id);
+
+/* An opaque type that represents the underlying TLS session */
+typedef struct NTS_TLS NTS_TLS;
+
+/* Perform key extraction on the TLS session using the specified algorithm_type. C2S and S2C must point to
+ * buffers that provide key_capacity amount of bytes.
+ *
+ * RETURNS
+ * 0 upon success
+ * a negative value upon failure:
+ * -EBADE OpenSSL error
+ * -ENOBUFS not enough space in buffer
+ * -EINVAL unkown AEAD
+ */
+int NTS_TLS_extract_keys(NTS_TLS *session, NTS_AEADAlgorithmType aead, uint8_t *c2s, uint8_t *s2c, int key_capacity);
+
+/* Setup a ready-to-use TLS session for hostname, on the connected socket, ready to begin a TLS handshake.
+ *
+ * RETURNS
+ * A pointer to a ready-to-use TLS session, NULL upon failure (and then the error is stored in NTS_TLS_error)
+ */
+NTS_TLS* NTS_TLS_setup(const char *hostname, int socket);
+
+/* Perform a TLS handshake
+ *
+ * RETURNS
+ * > 0 upon success
+ * 0 if it needs to be retried (e.g. if the socket is non-blocking)
+ * < 0 upon permanent failure
+ *
+ */
+int NTS_TLS_handshake(NTS_TLS *session);
+
+/* Shutdowns a TLS session and frees all resources, closes the associated socket
+ * Also sets the NTS_TLS* object itself to NULL.
+ *
+ * RETURNS
+ * Nothing
+ */
+void NTS_TLS_close(NTS_TLS **session);
+
+/* Reading and writing data
+ *
+ * RETURNS
+ * > 0 the number of bytes processed
+ * 0 an error occurred, please retry
+ * < 0 an error occurred, do not retry
+ */
+ssize_t NTS_TLS_write(NTS_TLS *session, const void *buffer, size_t size);
+ssize_t NTS_TLS_read(NTS_TLS *session, void *buffer, size_t size);
diff --git a/src/timesync/nts_crypto.h b/src/timesync/nts_crypto.h
new file mode 100644
index 0000000000..2a9a259bf3
--- /dev/null
+++ b/src/timesync/nts_crypto.h
@@ -0,0 +1,43 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later
+ * Copyright © 2026 Trifecta Tech Foundation */
+#pragma once
+
+#include "nts.h"
+#include "nts_extfields.h"
+
+typedef struct AssociatedData {
+ const uint8_t *data;
+ const size_t length;
+} AssociatedData;
+
+/* encrypt the data in ptxt of ptxt_len bytes, and write it to ctxt, using the selected cryptoscheme and key
+ * the associated data should point to an array of NULL-terminated chunks of associated data
+ *
+ * caller should make sure that there is enough room in ptxt for holding the plaintext + one additional block
+ *
+ * RETURNS: the number of bytes in the ciphertext (< 0 indicates an error)
+ */
+int NTS_encrypt(uint8_t *ctxt,
+ int ctxt_len,
+ const uint8_t *ptxt,
+ int ptxt_len,
+ const AssociatedData *info,
+ const struct NTS_AEADParam *aead,
+ const uint8_t *key);
+
+/* decrypt the data in ctxt of ctxt_len bytes, and write it to ptxt, using the selected cryptoscheme and key
+ *
+ * the associated data should point to an array of NULL-terminated chunks of associated data
+ *
+ * caller should make sure that there is enough room in ptxt for holding the decrypted ciphertext;
+ * the size of the plaintext will always be less than or equal to the ciphertext ptxt
+ *
+ * RETURNS: the number of bytes in the decrypted plaintext (< 0 indicates an error)
+ */
+int NTS_decrypt(uint8_t *ptxt,
+ int ptxt_len,
+ const uint8_t *ctxt,
+ int ctxt_len,
+ const AssociatedData *info,
+ const struct NTS_AEADParam *aead,
+ const uint8_t *key);
diff --git a/src/timesync/nts_crypto_openssl.c b/src/timesync/nts_crypto_openssl.c
new file mode 100644
index 0000000000..d1efa35570
--- /dev/null
+++ b/src/timesync/nts_crypto_openssl.c
@@ -0,0 +1,239 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later
+ * Copyright © 2026 Trifecta Tech Foundation */
+
+#include <assert.h>
+#include <openssl/evp.h>
+#include <openssl/opensslv.h>
+
+#include "nts_crypto.h"
+#include "openssl-util.h"
+#include "timesyncd-forward.h"
+
+#if !OPENSSL_VERSION_PREREQ(3,0)
+# error Your OpenSSL version does not support SIV modes, need at least version 3.0.
+#endif
+
+#if defined(OPENSSL_WORKAROUND) && OPENSSL_VERSION_PREREQ(3,5)
+# warning The OpenSSL workaround is not necessary.
+#endif
+
+static const struct NTS_AEADParam supported_algos[] = {
+ { NTS_AEAD_AES_SIV_CMAC_256, 256/8, 16, 16, true, false, "AES-128-SIV" },
+ { NTS_AEAD_AES_SIV_CMAC_512, 512/8, 16, 16, true, false, "AES-256-SIV" },
+ { NTS_AEAD_AES_SIV_CMAC_384, 384/8, 16, 16, true, false, "AES-192-SIV" },
+#if OPENSSL_VERSION_PREREQ(3,2)
+ { NTS_AEAD_AES_128_GCM_SIV, 128/8, 16, 12, false, true, "AES-128-GCM-SIV" },
+ { NTS_AEAD_AES_256_GCM_SIV, 256/8, 16, 12, false, true, "AES-256-GCM-SIV" },
+#endif
+};
+
+const struct NTS_AEADParam* NTS_get_param(NTS_AEADAlgorithmType id) {
+ FOREACH_ELEMENT(algo, supported_algos)
+ if (algo->aead_id == id)
+ return algo;
+
+ return NULL;
+}
+
+typedef int init_f(EVP_CIPHER_CTX*, const EVP_CIPHER*, ENGINE*, const uint8_t*, const uint8_t*);
+typedef int upd_f(EVP_CIPHER_CTX*, uint8_t*, int*, const uint8_t*, int);
+
+static int process_assoc_data(
+ EVP_CIPHER_CTX *state,
+ const AssociatedData *info,
+ const struct NTS_AEADParam *aead,
+ init_f EVP_CryptInit_ex,
+ upd_f EVP_CryptUpdate) {
+
+ int r;
+
+ assert(state);
+ assert(info);
+ assert(aead);
+
+ /* process the associated data and nonce first */
+ const AssociatedData *last = NULL;
+ if (aead->nonce_is_iv) {
+ /* workaround for the OpenSSL GCM-SIV interface, where the IV is set directly in
+ * contradiction to the documentation;
+ * our interface *does* interpret the last AAD item as the siv/nonce
+ */
+ assert(info->data);
+ for (last = info; (last+1)->data != NULL; )
+ last++;
+
+ if (last->length != aead->nonce_size)
+ goto exit;
+
+ r = EVP_CryptInit_ex(state, NULL, NULL, NULL, last->data);
+ if (r == 0)
+ goto exit;
+ }
+
+ for ( ; info->data && info != last; info++) {
+ int len = 0;
+ r = EVP_CryptUpdate(state, NULL, &len, info->data, info->length);
+ if (r == 0)
+ goto exit;
+
+ assert((size_t)len == info->length);
+ }
+
+ return 1;
+exit:
+ return 0;
+}
+
+int NTS_encrypt(uint8_t *ctxt,
+ int ctxt_len,
+ const uint8_t *ptxt,
+ int ptxt_len,
+ const AssociatedData *info,
+ const struct NTS_AEADParam *aead,
+ const uint8_t *key) {
+
+ int r;
+ int bytes_encrypted = -1;
+ int len;
+
+ assert(ctxt);
+ assert(ctxt_len >= 0); /* see below */
+ assert(ptxt);
+ assert(ptxt_len >= 0); /* passed as an int since OpenSSL expects an int */
+ assert(info);
+ assert(aead);
+ assert(key);
+
+ _cleanup_(EVP_CIPHER_freep) EVP_CIPHER *cipher = NULL;
+ _cleanup_(EVP_CIPHER_CTX_freep) EVP_CIPHER_CTX *state = EVP_CIPHER_CTX_new();
+ if (!state)
+ goto exit;
+
+ cipher = EVP_CIPHER_fetch(NULL, aead->cipher_name, NULL);
+ if (!cipher)
+ goto exit;
+
+ /* check that the ciphertext length is large enough */
+ if (ctxt_len < ptxt_len + aead->block_size)
+ goto exit;
+
+ uint8_t *ctxt_start = ctxt;
+ uint8_t *tag;
+ if (aead->tag_first) {
+ tag = ctxt;
+ ctxt += aead->block_size;
+ } else
+ tag = ctxt + ptxt_len;
+
+ r = EVP_EncryptInit_ex(state, cipher, NULL, key, NULL);
+ if (r == 0)
+ goto exit;
+
+ r = process_assoc_data(state, info, aead, EVP_EncryptInit_ex, EVP_EncryptUpdate);
+ if (r == 0)
+ goto exit;
+
+ /* encrypt data */
+ r = EVP_EncryptUpdate(state, ctxt, &len, ptxt, ptxt_len);
+ if (r == 0)
+ goto exit;
+
+ assert(len <= ptxt_len);
+ ctxt += len;
+
+ r = EVP_EncryptFinal_ex(state, ctxt, &len);
+ if (r == 0)
+ goto exit;
+
+ assert(len <= aead->block_size);
+ ctxt += len;
+ assert(ctxt - ctxt_start == ptxt_len + aead->tag_first * aead->block_size);
+
+ /* append/prepend the AEAD tag */
+ r = EVP_CIPHER_CTX_ctrl(state, EVP_CTRL_AEAD_GET_TAG, aead->block_size, tag);
+ if (r == 0)
+ goto exit;
+
+ bytes_encrypted = ptxt_len + aead->block_size;
+exit:
+ return bytes_encrypted;
+}
+
+int NTS_decrypt(uint8_t *ptxt,
+ int ptxt_len,
+ const uint8_t *ctxt,
+ int ctxt_len,
+ const AssociatedData *info,
+ const struct NTS_AEADParam *aead,
+ const uint8_t *key) {
+
+ int r;
+ int bytes_decrypted = -1;
+ int len;
+
+ assert(ptxt);
+ assert(ptxt_len >= 0); /* see below */
+ assert(ctxt);
+ assert(ctxt_len >= 0); /* passed as an int since OpenSSL expects an int */
+ assert(info);
+ assert(aead);
+ assert(key);
+
+ _cleanup_(EVP_CIPHER_freep) EVP_CIPHER *cipher = NULL;
+ _cleanup_(EVP_CIPHER_CTX_freep) EVP_CIPHER_CTX *state = EVP_CIPHER_CTX_new();
+ if (!state)
+ goto exit;
+
+ /* check that the ciphertext size is valid */
+ if (ctxt_len < aead->block_size || ptxt_len < ctxt_len - aead->block_size)
+ goto exit;
+
+ cipher = EVP_CIPHER_fetch(NULL, aead->cipher_name, NULL);
+ if (!cipher)
+ goto exit;
+
+ /* set the AEAD tag */
+ const uint8_t *tag;
+ if (aead->tag_first) {
+ tag = ctxt;
+ ctxt += aead->block_size;
+ } else
+ tag = ctxt + ctxt_len - aead->block_size;
+
+ ctxt_len -= aead->block_size;
+
+ r = EVP_DecryptInit_ex(state, cipher, NULL, key, NULL);
+ if (r == 0)
+ goto exit;
+
+ r = EVP_CIPHER_CTX_ctrl(state, EVP_CTRL_AEAD_SET_TAG, aead->block_size, (uint8_t*)tag);
+ if (r == 0)
+ goto exit;
+
+ r = process_assoc_data(state, info, aead, EVP_DecryptInit_ex, EVP_DecryptUpdate);
+ if (r == 0)
+ goto exit;
+
+ uint8_t *ptxt_start = ptxt;
+
+ /* decrypt data */
+ r = EVP_DecryptUpdate(state, ptxt, &len, ctxt, ctxt_len);
+ if (r == 0)
+ goto exit;
+
+ assert(len <= ctxt_len);
+ ptxt += len;
+
+ r = EVP_DecryptFinal_ex(state, ptxt, &len);
+ if (r == 0)
+ goto exit;
+
+ assert(len <= aead->block_size);
+ ptxt += len;
+
+ assert(ptxt - ptxt_start == ctxt_len);
+
+ bytes_decrypted = ctxt_len;
+exit:
+ return bytes_decrypted;
+}
diff --git a/src/timesync/nts_error.c b/src/timesync/nts_error.c
new file mode 100644
index 0000000000..bc4eecaa55
--- /dev/null
+++ b/src/timesync/nts_error.c
@@ -0,0 +1,27 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later
+ * Copyright © 2026 Trifecta Tech Foundation */
+
+#include "assert-fundamental.h"
+#include "nts.h"
+
+#define ERROR(kind) case kind: return &#kind[4]
+
+const char *NTS_error_string(enum NTS_ErrorType error) {
+ switch (error) {
+ ERROR(NTS_SERVER_UNKNOWN_CRIT_RECORD);
+ ERROR(NTS_SERVER_BAD_REQUEST);
+ ERROR(NTS_SERVER_INTERNAL_ERROR);
+
+ ERROR(NTS_UNEXPECTED_WARNING);
+ ERROR(NTS_BAD_RESPONSE);
+ ERROR(NTS_INTERNAL_CLIENT_ERROR);
+ ERROR(NTS_NO_PROTOCOL);
+ ERROR(NTS_NO_AEAD);
+ ERROR(NTS_INSUFFICIENT_DATA);
+ ERROR(NTS_UNKNOWN_CRIT_RECORD);
+ case NTS_SUCCESS:
+ return "Success?";
+ default:
+ assert_not_reached();
+ }
+}
diff --git a/src/timesync/nts_extfields.c b/src/timesync/nts_extfields.c
new file mode 100644
index 0000000000..dae2f651ee
--- /dev/null
+++ b/src/timesync/nts_extfields.c
@@ -0,0 +1,278 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later
+ * Copyright © 2026 Trifecta Tech Foundation */
+
+#include <assert.h>
+#include <endian.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "memory-util.h"
+#include "nts_crypto.h"
+#include "nts_extfields.h"
+#include "random-util.h"
+
+#ifndef ENCRYPTED_PLACEHOLDERS
+#define ENCRYPTED_PLACEHOLDERS 0
+#endif
+
+typedef struct {
+ uint8_t *data;
+ uint8_t *data_end;
+} slice;
+
+static size_t capacity(const slice *p) {
+ return p->data_end - p->data;
+}
+
+static int write_ntp_ext_field(slice *buf, uint16_t type, void *contents, uint16_t len, uint16_t size) {
+ assert(buf);
+
+ /* enforce minimum size */
+ if (size < len+4) size = len+4;
+ /* pad to a dword boundary */
+ uint16_t padded_len = (size+3) & ~3;
+ int padding = padded_len - (len+4);
+
+ if (capacity(buf) < padded_len)
+ return 0;
+
+ if (contents)
+ memmove(buf->data+4, contents, len);
+ else
+ memzero(buf->data+4, len);
+
+ type = htobe16(type);
+ memcpy(buf->data, &type, 2);
+ len = htobe16(padded_len);
+ memcpy(buf->data+2, &len, 2);
+
+ buf->data += padded_len;
+ memzero(buf->data - padding, padding);
+ return padded_len;
+}
+
+enum extfields {
+ UniqueIdentifier = 0x0104,
+ Cookie = 0x0204,
+ CookiePlaceholder = 0x0304,
+ AuthEncExtFields = 0x0404,
+ NoOpField = 0x0200,
+};
+
+int NTS_add_extension_fields(
+ uint8_t dest[static 1280],
+ const struct NTS_Query *nts,
+ uint8_t (*identifier)[32]) {
+
+ int r;
+
+ assert(dest);
+ assert(nts);
+ assert(identifier);
+
+ slice buf = { dest, dest + 1280 };
+
+ /* skip beyond regular ntp portion */
+ buf.data += 48;
+
+ /* generate unique identifier */
+ if (crypto_random_bytes(*identifier, sizeof(*identifier)) != 0)
+ goto exit;
+
+ r = write_ntp_ext_field(&buf, UniqueIdentifier, *identifier, sizeof(*identifier), 16);
+ if (r == 0)
+ goto exit;
+
+ /* write cookie field */
+ r = write_ntp_ext_field(&buf, Cookie, nts->cookie.data, nts->cookie.length, 16);
+ if (r == 0)
+ goto exit;
+
+ /* write unencrypted extra cookiefields */
+ int placeholders = nts->extra_cookies;
+ for ( ; placeholders > ENCRYPTED_PLACEHOLDERS; placeholders--) {
+ r = write_ntp_ext_field(&buf, CookiePlaceholder, NULL, nts->cookie.length, 16);
+ if (r == 0)
+ goto exit;
+ }
+
+ /* --- cobble together the extension fields extension field --- */
+
+ /* this represents "N_REQ" in the RFC */
+ uint8_t const req_nonce_len = nts->cipher.nonce_size;
+ uint8_t const nonce_len = req_nonce_len; /* RFC8915 permits < req_nonce_len, but many servers wont like it */
+
+#if ENCRYPTED_PLACEHOLDERS
+ uint8_t EF[1024] = { 0, nonce_len, 0, 0, };
+#else
+ uint8_t EF[64] = { 0, nonce_len, 0, 0, }; /* 64 bytes are plenty */
+#endif
+ void *const EF_ciphertext_len = EF+2;
+ uint8_t *const EF_nonce = EF+4;
+ uint8_t *const EF_payload = EF_nonce + nonce_len;
+
+ assert((nonce_len & 3) == 0);
+ assert((req_nonce_len & 3) == 0 && req_nonce_len <= 16);
+
+ /* re-use the remaining buffer as a temporary scratch area for plaintext;
+ since we are encrypting this and writing it to the buffer, it will be guaranteed
+ to be overwritten */
+ slice ptxt = buf;
+
+#if defined(OPENSSL_WORKAROUND)
+ /* bug in OpenSSL: https://github.com/openssl/openssl/issues/26580,
+ which means that a ciphertext HAS TO BE PRESENT */
+ if (placeholders == 0) {
+ r = write_ntp_ext_field(&ptxt, NoOpField, NULL, 0, 0);
+ if (r == 0)
+ goto exit;
+ }
+#endif
+ while (placeholders-- > 0) {
+ r = write_ntp_ext_field(&ptxt, CookiePlaceholder, NULL, nts->cookie.length, 0);
+ if (r == 0)
+ goto exit;
+ }
+
+ /* generate the nonce */
+ if (crypto_random_bytes(EF_nonce, nonce_len) != 0)
+ goto exit;
+
+ AssociatedData info[] = {
+ { dest, buf.data - dest }, /* aad */
+ { EF_nonce, nonce_len }, /* nonce */
+ { },
+ };
+
+ int ptxt_len = ptxt.data - buf.data;
+ assert((int)sizeof(EF) - (EF_payload - EF) >= ptxt_len + nts->cipher.block_size);
+
+ int EF_capacity = sizeof(EF) - (EF_payload - EF);
+ int ctxt_len = NTS_encrypt(EF_payload, EF_capacity, buf.data, ptxt_len, info, &nts->cipher, nts->c2s_key);
+
+ assert(ctxt_len <= EF_capacity); /* failing this would be a serious error, try to run to the exit */
+ if (ctxt_len < 0)
+ goto exit;
+
+ /* add padding if we used a too-short nonce */
+ int ef_len = 4 + ctxt_len + nonce_len + (nonce_len < req_nonce_len)*(req_nonce_len - nonce_len);
+
+ /* set the ciphertext length */
+ uint16_t encoded_len = htobe16(ctxt_len);
+ memcpy(EF_ciphertext_len, &encoded_len, 2);
+
+ r = write_ntp_ext_field(&buf, AuthEncExtFields, EF, ef_len, 28);
+ if (r == 0)
+ goto exit;
+
+ return buf.data - dest;
+exit:
+ return 0;
+}
+
+/* caller checks memory bounds */
+static void decode_hdr(uint16_t *restrict a, uint16_t *restrict b, uint8_t *bytes) {
+ memcpy(a, bytes, 2), memcpy(b, bytes+2, 2);
+ *a = be16toh(*a), *b = be16toh(*b);
+}
+
+int NTS_parse_extension_fields(
+ uint8_t src[static 1280],
+ size_t src_len,
+ const struct NTS_Query *nts,
+ struct NTS_Receipt *fields) {
+
+ assert(src);
+ assert(src_len >= 48 && src_len <= 1280);
+ assert(nts);
+ assert(fields);
+
+ slice buf = { src + 48, src + src_len };
+ bool processed = false;
+
+ while (capacity(&buf) >= 4) {
+ uint16_t type, len;
+ decode_hdr(&type, &len, buf.data);
+ if (len < 4 || capacity(&buf) < len)
+ goto exit;
+
+ switch (type) {
+ case UniqueIdentifier:
+ /* the length indicator contains the size of the header (4 bytes); the identifier
+ * itself is expected to be 32 bytes */
+ if (len - 4 != 32)
+ goto exit;
+
+ fields->identifier = (uint8_t (*)[32])(buf.data + 4);
+ processed = true;
+ break;
+ case AuthEncExtFields: {
+ uint16_t nonce_len, ciph_len;
+ decode_hdr(&nonce_len, &ciph_len, buf.data + 4);
+ /* check that the advertised nonce / cipher lengths + header don't exceed the outer length,
+ * which would be a malicious packet; the sizes don't need to match exactly since there may
+ * also be padding here */
+ if (nonce_len + ciph_len + 8 > len)
+ goto exit;
+
+ uint8_t *nonce = buf.data + 8;
+ uint8_t *content = nonce + nonce_len;
+
+ AssociatedData info[] = {
+ { src, buf.data - src }, /* aad */
+ { nonce, nonce_len }, /* nonce */
+ { },
+ };
+
+ uint8_t *plaintext = content;
+ int plain_len = NTS_decrypt(plaintext, ciph_len, content, ciph_len, info, &nts->cipher, nts->s2c_key);
+
+ assert(plain_len < ciph_len); /* failing this would be a serious error, try to run to the exit */
+ if (plain_len < 0)
+ goto exit;
+
+ slice plain = { plaintext, plaintext + plain_len };
+ unsigned cookies = 0;
+ zero(fields->new_cookie);
+
+ while (capacity(&plain) >= 4) {
+ uint16_t inner_type, inner_len;
+ decode_hdr(&inner_type, &inner_len, plain.data);
+ /* check that our buffer has enough room and the advertised length is valid */
+ if (capacity(&plain) < inner_len || inner_len < 4)
+ goto exit;
+
+ /* only care about cookies */
+ switch (inner_type) {
+ case Cookie:
+ if (cookies < ELEMENTSOF(fields->new_cookie)) {
+ fields->new_cookie[cookies].data = plain.data + 4;
+ fields->new_cookie[cookies].length = inner_len - 4;
+ }
+ cookies++;
+ break;
+
+ default:
+ /* ignore any other field */;
+ }
+
+ plain.data += inner_len;
+ }
+
+ /* ignore any further fields after this,
+ * since they are not authenticated */
+ return processed ? plain.data - src : 0;
+ }
+
+ default:
+ /* ignore unknown fields */
+ ;
+ }
+
+ buf.data += len;
+ }
+
+exit:
+ return 0;
+}
diff --git a/src/timesync/nts_extfields.h b/src/timesync/nts_extfields.h
new file mode 100644
index 0000000000..5d707b58db
--- /dev/null
+++ b/src/timesync/nts_extfields.h
@@ -0,0 +1,41 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later
+ * Copyright © 2026 Trifecta Tech Foundation */
+#pragma once
+
+#include "nts.h"
+
+typedef struct NTS_Query {
+ struct NTS_Cookie cookie;
+ const uint8_t *c2s_key, *s2c_key;
+ struct NTS_AEADParam cipher;
+ uint8_t extra_cookies;
+} NTS_Query;
+
+typedef struct NTS_Receipt {
+ uint8_t (*identifier)[32];
+ struct NTS_Cookie new_cookie[8];
+} NTS_Receipt;
+
+/* Render NTP extension fields in the provided buffer based on the configuration in the NTS struct.
+ * The identifier must point to a buffer that will hold a generated unique identifier upon success.
+ *
+ * RETURNS
+ * The amount of data encoded in bytes (including NTP packet size). Zero bytes encoded indicates an error (in which case the
+ * contents of uniq_ident are unspecified)
+ */
+int NTS_add_extension_fields(
+ uint8_t dest[static 1280],
+ const struct NTS_Query *nts,
+ uint8_t (*identifier)[32]);
+
+/* Processed the NTP extension fields in the provided buffer based on the configuration in the NTS struct,
+ * and make this information available in the NTS_Receipt struct.
+ *
+ * RETURNS
+ * The amount of data processed in bytes (including the NTP packet size). Zero bytes encoded indicates an error.
+ */
+int NTS_parse_extension_fields(
+ uint8_t src[static 1280],
+ size_t src_len,
+ const struct NTS_Query *nts,
+ struct NTS_Receipt *fields);
diff --git a/src/timesync/nts_packet.c b/src/timesync/nts_packet.c
new file mode 100644
index 0000000000..1cb7b7efc5
--- /dev/null
+++ b/src/timesync/nts_packet.c
@@ -0,0 +1,318 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later
+ * Copyright © 2026 Trifecta Tech Foundation */
+
+#include <assert.h>
+#include <endian.h>
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+#include <string.h>
+
+#include "nts.h"
+#include "timesyncd-forward.h"
+#include "utf8.h"
+
+/* should we emit the NTS record that forces chrony to be 'compliant'?
+ * for info see: https://chrony-project.org/doc/spec/nts-compliant-128gcm.html
+ */
+#define CHRONY_WORKAROUND
+
+enum NTS_RecordType {
+ /* critical */
+ NTS_EndOfMessage = 0,
+ NTS_NextProto = 1,
+ NTS_Error = 2,
+ NTS_Warning = 3,
+ /* may be critical */
+ NTS_AEADAlgorithm = 4,
+ /* never critical */
+ NTS_NTPv4Cookie = 5,
+ /* never critical by clients, may be critical by servers */
+ NTS_NTPv4Server = 6,
+ NTS_NTPv4Port = 7,
+ /* https://chrony-project.org/doc/spec/nts-compliant-128gcm.html */
+ NTS_Chrony_BugWorkaround = 1024,
+};
+
+enum NTS_ProtocolType {
+ NTS_PROTO_NTPv4 = 0,
+};
+
+typedef struct {
+ uint8_t *data;
+ uint8_t *data_end;
+} slice;
+
+static size_t capacity(const slice *p) {
+ return p->data_end - p->data;
+}
+
+/* does not check bounds */
+static void push_u16(uint8_t **data, uint16_t value) {
+ value = htobe16(value);
+ memcpy(*data, &value, 2);
+ *data += 2;
+}
+
+static uint16_t u16_from_bytes(uint8_t bytes[2]) {
+ uint16_t value;
+ memcpy(&value, bytes, 2);
+ return be16toh(value);
+}
+
+struct NTS_Record {
+ uint16_t type;
+ slice body;
+};
+
+static int32_t NTS_decode_u16(struct NTS_Record *record) {
+ assert(record);
+
+ if (capacity(&record->body) < 2)
+ return -NTS_INSUFFICIENT_DATA;
+
+ uint16_t result = u16_from_bytes(record->body.data);
+ record->body.data += 2;
+ return result;
+}
+
+static int NTS_decode_record(slice *message, struct NTS_Record *record) {
+ assert(message);
+ assert(record);
+
+ size_t bytes_remaining = capacity(message);
+ if (bytes_remaining < 4)
+ /* not enough bytes to decode a header */
+ return -NTS_INSUFFICIENT_DATA;
+
+ bool is_critical = message->data[0] >> 7;
+
+ uint16_t body_size = u16_from_bytes(message->data + 2);
+ if (body_size > bytes_remaining - 4)
+ /* not enough data in the slice to decode this header */
+ return -NTS_INSUFFICIENT_DATA;
+
+ record->type = u16_from_bytes(message->data) & 0x7FFF;
+ record->body.data = message->data += 4;
+ record->body.data_end = message->data += body_size;
+
+ switch (record->type) {
+ case NTS_Error:
+ case NTS_Warning:
+ case NTS_NTPv4Port:
+ if (body_size != 2)
+ goto error;
+ break;
+ case NTS_EndOfMessage:
+ if (body_size != 0)
+ goto error;
+ break;
+ case NTS_AEADAlgorithm:
+ case NTS_NextProto:
+ if (body_size % 2 != 0)
+ goto error;
+ break;
+ default:
+ if (is_critical)
+ return -NTS_UNKNOWN_CRIT_RECORD;
+ break;
+ case NTS_NTPv4Server:
+ case NTS_NTPv4Cookie:
+ break;
+ }
+
+ return 0;
+
+error:
+ /* there was an inconsistency in the record */
+ return -NTS_BAD_RESPONSE;
+}
+
+static int NTS_encode_record_u16(
+ slice *message,
+ bool critical,
+ enum NTS_RecordType type,
+ const uint16_t *data, size_t num_words) {
+
+ assert(message);
+ assert(num_words == 0 || data);
+
+ size_t bytes_remaining = capacity(message);
+ if (num_words >= 0x8000 || bytes_remaining < 4 + num_words*2)
+ /* not enough space */
+ return -NTS_INSUFFICIENT_DATA;
+
+ if (critical)
+ type |= 0x8000;
+
+ push_u16(&message->data, type);
+ push_u16(&message->data, num_words * 2);
+
+ for (size_t i = 0; i < num_words; i++)
+ push_u16(&message->data, data[i]);
+
+ return 0;
+}
+
+int NTS_encode_request(
+ uint8_t *buffer,
+ size_t buf_size,
+ const NTS_AEADAlgorithmType *preferred_crypto) {
+
+ assert(buffer);
+
+ slice request = { buffer, buffer + buf_size };
+
+ const uint16_t proto[] = { NTS_PROTO_NTPv4 };
+ const uint16_t aead_default[] = {
+ NTS_AEAD_AES_SIV_CMAC_256,
+ NTS_AEAD_AES_SIV_CMAC_512
+ }, *aead = aead_default;
+
+ size_t aead_len = ELEMENTSOF(aead_default);
+ if (preferred_crypto) {
+ aead = preferred_crypto;
+ for (aead_len = 0; preferred_crypto[aead_len] ; )
+ ++aead_len;
+ }
+
+ int result;
+ result = NTS_encode_record_u16(&request, true, NTS_NextProto, proto, ELEMENTSOF(proto));
+ if (result < 0)
+ return result;
+
+ result = NTS_encode_record_u16(&request, true, NTS_AEADAlgorithm, aead, aead_len);
+ if (result < 0)
+ return result;
+#ifdef CHRONY_WORKAROUND
+ result = NTS_encode_record_u16(&request, false, NTS_Chrony_BugWorkaround, NULL, 0);
+ if (result < 0)
+ return result;
+#endif
+ result = NTS_encode_record_u16(&request, true, NTS_EndOfMessage, NULL, 0);
+ if (result < 0)
+ return result;
+
+ return request.data - buffer;
+}
+
+int NTS_decode_response(uint8_t *buffer, size_t buf_size, struct NTS_Agreement *response) {
+ assert(buffer);
+ assert(response);
+
+ slice raw_response = { buffer, buffer+buf_size };
+ struct NTS_Record rec;
+
+ /* clear response */
+ size_t cookie_nr = 0;
+ bool is_ntp4 = false;
+ char *ntp_server_terminator = NULL;
+
+ /* make sure the result is only OK if we really succeed */
+ *response = (struct NTS_Agreement) { .error = NTS_INTERNAL_CLIENT_ERROR };
+
+ while (raw_response.data < raw_response.data_end) {
+ int val = NTS_decode_record(&raw_response, &rec);
+ if (val < 0) {
+ response->error = -val;
+ if (response->error == NTS_INSUFFICIENT_DATA)
+ return -ENODATA;
+ else
+ return -EBADMSG;
+ }
+
+ switch (rec.type) {
+ case NTS_Error:
+ val = NTS_decode_u16(&rec);
+ if (val < 0)
+ goto unexpected_end;
+
+ response->error = val;
+ return -EBADMSG;
+
+ case NTS_Warning:
+ val = NTS_decode_u16(&rec);
+ if (val < 0)
+ goto unexpected_end;
+
+ response->error = NTS_UNEXPECTED_WARNING;
+ return -EBADMSG;
+
+ case NTS_EndOfMessage:
+ if (ntp_server_terminator)
+ /* this hack saves having to allocate a string that we are going to keep in-memory */
+ *ntp_server_terminator = '\0';
+
+ if (is_ntp4 && response->aead_id != 0) {
+ response->error = NTS_SUCCESS;
+ return 0;
+ } else {
+ response->error = NTS_BAD_RESPONSE;
+ return -EBADMSG;
+ }
+
+ case NTS_NextProto:
+ /* confirm that NTPv4 is on offer */
+ do {
+ val = NTS_decode_u16(&rec);
+ if (val < 0) {
+ response->error = NTS_NO_PROTOCOL;
+ return -EBADMSG;
+ }
+ } while (val != NTS_PROTO_NTPv4);
+ is_ntp4 = true;
+ break;
+
+ case NTS_AEADAlgorithm:
+ /* confirm that one of the supported AEAD algo's is offered */
+ val = NTS_decode_u16(&rec);
+ if (val < 0 || !NTS_get_param(val)) {
+ response->error = NTS_NO_AEAD;
+ return -EBADMSG;
+ }
+ response->aead_id = val;
+ break;
+
+ case NTS_NTPv4Cookie:
+ /* ignore any cookies in excess of eight */
+ if (cookie_nr < ELEMENTSOF(response->cookie)) {
+ struct NTS_Cookie *cookie = &response->cookie[cookie_nr++];
+ cookie->data = rec.body.data;
+ cookie->length = rec.body.data_end - rec.body.data;
+ }
+ break;
+
+ case NTS_NTPv4Server:
+ /* do limited sanity check */
+ if (capacity(&rec.body) > 255) {
+ response->error = NTS_BAD_RESPONSE;
+ return -EBADMSG;
+ }
+
+ if (!ascii_is_valid_n((char *)rec.body.data, rec.body.data_end - rec.body.data)) {
+ response->error = NTS_BAD_RESPONSE;
+ return -EBADMSG;
+ }
+
+ response->ntp_server = (char *)rec.body.data;
+ ntp_server_terminator = (char *)rec.body.data_end;
+ break;
+
+ case NTS_NTPv4Port:
+ val = NTS_decode_u16(&rec);
+ if (val < 0)
+ goto unexpected_end;
+
+ response->ntp_port = val;
+ break;
+
+ default:
+ /* ignore unknown non-critical fields */
+ ;
+ }
+ }
+
+unexpected_end:
+ response->error = NTS_INSUFFICIENT_DATA;
+ return -ENODATA;
+}
diff --git a/src/timesync/nts_tls.c b/src/timesync/nts_tls.c
new file mode 100644
index 0000000000..2b85432b4f
--- /dev/null
+++ b/src/timesync/nts_tls.c
@@ -0,0 +1,167 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later
+ * Copyright © 2026 Trifecta Tech Foundation */
+
+#include <assert.h>
+#include <openssl/ssl.h>
+#include <string.h>
+#include <unistd.h>
+
+#include "nts.h"
+#include "openssl-util.h"
+#include "timesyncd-forward.h"
+
+int NTS_TLS_extract_keys(
+ NTS_TLS *session,
+ NTS_AEADAlgorithmType aead,
+ uint8_t *c2s,
+ uint8_t *s2c,
+ int key_capacity) {
+
+ assert(session);
+ assert(c2s);
+ assert(s2c);
+
+ SSL *tls = (void *)session;
+
+ uint8_t *keys[] = { c2s, s2c };
+ const char label[] = "EXPORTER-network-time-security";
+
+ const struct NTS_AEADParam *info = NTS_get_param(aead);
+ if (!info)
+ return -EINVAL;
+ else if (info->key_size > key_capacity)
+ return -ENOBUFS;
+
+ for (int i=0; i < 2; i++) {
+ const uint8_t context[5] = { 0, 0, (aead >> 8) & 0xFF, aead & 0xFF, i };
+ if (SSL_export_keying_material(
+ tls,
+ keys[i], info->key_size,
+ label, strlen(label),
+ context, sizeof context, 1)
+ != 1)
+ return -EBADE;
+ }
+
+ return 0;
+}
+
+int NTS_TLS_handshake(NTS_TLS *session) {
+ assert(session);
+ SSL *tls = (void *)session;
+
+ int result = SSL_connect(tls);
+ if (result == 1)
+ return 1;
+
+ switch (SSL_get_error(tls, result)) {
+ case SSL_ERROR_ZERO_RETURN:
+ return -ECONNRESET;
+ case SSL_ERROR_WANT_READ:
+ case SSL_ERROR_WANT_WRITE:
+ return 0;
+ default:
+ return -EIO;
+ }
+}
+
+ssize_t NTS_TLS_write(NTS_TLS *session, const void *buffer, size_t size) {
+ assert(session);
+ assert(buffer);
+
+ SSL *tls = (void *)session;
+ int result = SSL_write(tls, buffer, size);
+ if (result > 0)
+ return result;
+
+ switch (SSL_get_error(tls, result)) {
+ case SSL_ERROR_WANT_READ:
+ case SSL_ERROR_WANT_WRITE:
+ return 0;
+ default:
+ return -EIO;
+ }
+}
+
+ssize_t NTS_TLS_read(NTS_TLS *session, void *buffer, size_t size) {
+ assert(session);
+ assert(buffer);
+
+ SSL *tls = (void *)session;
+ int result = SSL_read(tls, buffer, size);
+ if (result > 0)
+ return result;
+
+ switch (SSL_get_error(tls, result)) {
+ case SSL_ERROR_WANT_READ:
+ case SSL_ERROR_WANT_WRITE:
+ return 0;
+ default:
+ return -EIO;
+ }
+}
+
+void NTS_TLS_close(NTS_TLS **session) {
+ assert(session);
+ if (*session == NULL)
+ return;
+
+ SSL *tls = (SSL*) *session;
+ *session = NULL;
+
+ /* unidirectional closing is enough */
+ (void) SSL_shutdown(tls);
+ SSL_free(tls);
+}
+
+NTS_TLS* NTS_TLS_setup(
+ const char *hostname,
+ int socket) {
+
+ int r;
+
+ assert(hostname);
+
+ _cleanup_(SSL_CTX_freep) SSL_CTX *ctx = SSL_CTX_new(TLS_client_method());
+ if (!ctx)
+ return NULL;
+
+ SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, NULL);
+ r = SSL_CTX_set_default_verify_paths(ctx);
+ if (r != 1)
+ return NULL;
+
+ r = SSL_CTX_set_min_proto_version(ctx, TLS1_3_VERSION);
+ if (r != 1)
+ return NULL;
+
+ _cleanup_(SSL_freep) SSL *tls = SSL_new(ctx);
+ if (!tls)
+ return NULL;
+
+ r = SSL_set1_host(tls, hostname);
+ if (r != 1)
+ return NULL;
+
+ r = SSL_set_tlsext_host_name(tls, hostname);
+ if (r != 1)
+ return NULL;
+
+ unsigned char alpn[] = "\x07ntske/1";
+ r = SSL_set_alpn_protos(tls, alpn, strlen((char*)alpn));
+ if (r != 0)
+ return NULL;
+
+ BIO *bio = BIO_new(BIO_s_socket());
+ if (!bio)
+ return NULL;
+
+ BIO_set_fd(bio, socket, BIO_NOCLOSE);
+ SSL_set_bio(tls, bio, bio);
+
+ /* move the initialized session object to the caller */
+ NTS_TLS *ret_ptr = (void *)tls;
+ tls = NULL;
+
+ return ret_ptr;
+}
diff --git a/src/timesync/test-nts-mockserver.c b/src/timesync/test-nts-mockserver.c
new file mode 100644
index 0000000000..78d1ba6266
--- /dev/null
+++ b/src/timesync/test-nts-mockserver.c
@@ -0,0 +1,279 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later
+ * Copyright © 2026 Trifecta Tech Foundation */
+
+/* This is a mock NTS server that is only used for integration tests.
+ * Any error in the protocol quickly results in an assert, and it can
+ * only communicate with a single client (hence why the NTS cookies
+ * do not matter)
+ */
+
+#include <arpa/inet.h>
+#include <netinet/in.h>
+#include <openssl/ssl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/random.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#include "nts.h"
+#include "nts_crypto.h"
+#include "nts_extfields.h"
+#include "memory-util.h"
+#include "timesyncd-ntp-message.h"
+
+/* always pick this AEAD */
+static const NTS_AEADAlgorithmType algo = NTS_AEAD_AES_SIV_CMAC_384;
+
+/* always pick this NTP port */
+static const uint16_t Port = 12345;
+
+typedef uint8_t AEADKey[64];
+
+static uint32_t get_current_ntp_sec(void) {
+ struct timespec time;
+ clock_gettime(CLOCK_REALTIME, &time);
+
+ return time.tv_sec + OFFSET_1900_1970; /* wrap around is intended */
+}
+
+static struct ntp_ts ntp_time(uint32_t secs) {
+ return (struct ntp_ts){ .sec = htobe32(secs), .frac = 0 };
+}
+
+/* we want to fail but not actually cause a core dump since this will run in
+ * integration tests; in some scenarios failure of this server is precisely the point
+ */
+#define soft_assert(condition) \
+ if (!(condition)) { \
+ fprintf(stderr, "server failed: %s (line %d)\n", #condition, __LINE__); \
+ exit(1); \
+ }
+
+static void serve_ntp_request(int sock, AEADKey c2s, AEADKey s2c, int sabotage) {
+ struct sockaddr_in client = {};
+ struct ntp_msg packet;
+ uint8_t buf[1280];
+
+ socklen_t addrlen = sizeof(client);
+ int len = recvfrom(sock, buf, sizeof(buf), MSG_WAITALL, (struct sockaddr*)&client, &addrlen);
+
+ soft_assert(len >= 48);
+
+ const struct NTS_AEADParam *cipher = NTS_get_param(algo);
+ soft_assert(cipher);
+
+ memcpy(&packet, buf, sizeof(packet));
+
+ uint8_t unique_id[32];
+ if (len > 48) {
+ /* We only parse the extension fields to check the authenticity tag; parse_extension_fields
+ * is meant for use in clients, not servers so it'll ignore Cookie Placeholders.
+ * Also note that the order of the s2c and c2s keys has to be reversed. */
+ struct NTS_Query const query = {
+ { (void*)"42", 2 },
+ s2c, c2s,
+ *cipher,
+ 0,
+ };
+ struct NTS_Receipt rcpt;
+ soft_assert(NTS_parse_extension_fields(buf, len, &query, &rcpt) > 0);
+ /* getting "new cookies" from a client is an error */
+ soft_assert(rcpt.new_cookie->data == NULL);
+
+ memcpy(unique_id, rcpt.identifier, 32);
+ }
+
+ /* simulate a SNTP reponse - you are always 42 seconds behind */
+ uint64_t reply_time = get_current_ntp_sec() + 42;
+
+ packet.field = 044;
+ packet.stratum = 15;
+ packet.reference_time = (struct ntp_ts){ 0, 0 };
+ packet.origin_time = packet.trans_time;
+ packet.recv_time = ntp_time(reply_time);
+ packet.trans_time = ntp_time(reply_time);
+
+ if (len > 48 && sabotage <= 1) {
+ int padding = 0;
+ uint16_t payload[] = {
+ /* Always send two cookies to see what happens */
+ htobe16(0x0204 /*Cookie*/), htobe16(8), htobe16(1), htobe16(1),
+ htobe16(0x0204 /*Cookie*/), htobe16(8), htobe16(1), htobe16(2),
+ };
+ static_assert(sizeof(payload)%4 == 0, "payload must dword-padded");
+
+ uint16_t id_field[] = {
+ htobe16(0x0104 /*UniqId*/), htobe16(36),
+ 2, 4, 6, 8,10,12,14,16,18,20,22,24,26,28,30,32,
+ };
+ memcpy(id_field+2, unique_id, sizeof(unique_id));
+ uint16_t auth_enc_field[] = {
+ htobe16(0x0404 /*AE Fld*/), htobe16(8+cipher->nonce_size+cipher->block_size+sizeof(payload)+padding),
+ htobe16(cipher->nonce_size),
+ htobe16(cipher->block_size+sizeof(payload)),
+ };
+
+ zero(buf);
+ uint8_t *p = buf;
+ p = mempcpy(p, &packet, sizeof(packet));
+ p = mempcpy(p, id_field, sizeof(id_field));
+ p = mempcpy(p, auth_enc_field, sizeof(auth_enc_field));
+
+ AssociatedData info[] = {
+ { buf, sizeof(packet) + sizeof(id_field) },
+ { p, cipher->nonce_size },
+ {},
+ };
+
+ int ciphertext = NTS_encrypt(
+ p + cipher->nonce_size, sizeof(buf) - (p - buf - cipher->nonce_size),
+ (uint8_t*)payload, sizeof(payload),
+ info,
+ cipher, s2c
+ );
+
+ soft_assert(ciphertext > 0);
+ p += cipher->nonce_size + ciphertext + padding;
+ if (sabotage) {
+ /* flip a random bit */
+ uint8_t index;
+ getrandom(&index, 1, 0);
+ buf[index % 48] ^= 1;
+ }
+
+ sendto(sock, buf, p - buf, MSG_CONFIRM, (struct sockaddr*)&client, addrlen);
+ } else {
+ sendto(sock, &packet, sizeof(packet), MSG_CONFIRM, (struct sockaddr*)&client, addrlen);
+ }
+
+ close(sock);
+}
+
+static int alpn_select(
+ SSL *ssl,
+ const unsigned char **out,
+ unsigned char *outlen,
+ const unsigned char *in,
+ unsigned int inlen,
+ void *arg) {
+
+ (void) ssl;
+ (void) arg;
+ soft_assert(SSL_select_next_proto((unsigned char**)out, outlen, (unsigned char*)"\x07ntske/1", 8, in, inlen) == OPENSSL_NPN_NEGOTIATED);
+ return SSL_TLSEXT_ERR_OK;
+}
+
+static void wait_for_nts_ke(AEADKey c2s, AEADKey s2c, int sabotage) {
+ /* configure TLS */
+ SSL_CTX *ctx = SSL_CTX_new(TLS_server_method());
+ soft_assert(ctx);
+
+ soft_assert(SSL_CTX_use_certificate_chain_file(ctx, "server.crt") > 0);
+ soft_assert(SSL_CTX_use_PrivateKey_file(ctx, "server.key", SSL_FILETYPE_PEM) > 0);
+
+ SSL_CTX_set_alpn_select_cb(ctx, alpn_select, NULL);
+
+ SSL *tls = SSL_new(ctx);
+ soft_assert(tls);
+
+ /* await the TCP connect */
+ BIO *acceptor = BIO_new_accept("4460");
+ soft_assert(acceptor);
+ soft_assert(BIO_do_accept(acceptor) > 0);
+ soft_assert(BIO_do_accept(acceptor) > 0);
+ BIO *bio = BIO_pop(acceptor);
+ close(BIO_get_fd(acceptor, NULL));
+
+ soft_assert(bio);
+
+ if (sabotage > 5) {
+ /* refuse to shake hands */
+ sleep(20);
+ }
+ if (sabotage > 4) {
+ /* drop the horn */
+ exit(0);
+ }
+
+ SSL_set_bio(tls, bio, bio);
+
+ soft_assert(SSL_accept(tls) > 0);
+
+ /* read the NTS packet */
+ struct NTS_Agreement NTS;
+ int readbytes;
+ uint8_t buf[1280];
+ readbytes = SSL_read(tls, buf, sizeof(buf));
+ soft_assert(readbytes > 0);
+
+ if (sabotage > 3) {
+ /* silent treatment */
+ exit(0);
+ }
+
+ if (NTS_decode_response(buf, readbytes, &NTS) < 0) {
+ printf("NTS error: %s (read %d bytes)\n", NTS_error_string(NTS.error), readbytes);
+ abort();
+ }
+
+ /* store the key */
+ soft_assert(NTS_TLS_extract_keys((void*)tls, algo, c2s, s2c, sizeof(AEADKey)) == 0);
+
+ /* custom hostname is intentionally padded to 10 bytes, so "127.0.0.01" is not a typo */
+ const char ntphost[] = "127.0.0.01";
+ static_assert(strlen(ntphost) == 10, "sanity check failed");
+
+ /* send a static reply */
+ uint16_t reply[] = {
+ htobe16(6/*NTPv4Server*/), htobe16(10), 0,0,0,0,0, /* filled in below */
+ htobe16(1/*NextProto*/), htobe16(2), htobe16(0),
+ htobe16(4/*AEADAlgorithm*/), htobe16(2), htobe16(algo),
+ htobe16(7/*NTPv4Port*/), htobe16(2), htobe16(12345),
+ /* only send 2 cookies just to see what happens */
+ htobe16(5/*NTPv4Cookie*/), htobe16(4), htobe16(0), htobe16(1),
+ htobe16(5/*NTPv4Cookie*/), htobe16(4), htobe16(0), htobe16(2),
+ htobe16(0/*EndOfMessage*/ | 0x8000), htobe16(0),
+ };
+ memcpy(reply+2, ntphost, sizeof(ntphost));
+ if (sabotage > 2) {
+ /* tamper with the length of the ntp server field, make sure it's not "10" */
+ unsigned char *p = (unsigned char*) reply;
+ getrandom(p+3, 1, 0);
+ p[3] = (p[3] % 32 + 1) ^ 10;
+ }
+
+ SSL_write(tls, reply, sizeof(reply));
+ SSL_free(tls);
+ SSL_CTX_free(ctx);
+}
+
+/* the number of arguments decide at which
+ * points in the NTS process a hiccup is simulated
+ */
+int main(int argc, char **argv) {
+ AEADKey c2s, s2c;
+ int sabo = argc>1 ? atoi(argv[1]) : 0;
+
+ /* bind the NTP socket ahead of time to prevent a race */
+ int sock = socket(AF_INET, SOCK_DGRAM, 0);
+ soft_assert(sock > 0);
+
+ struct sockaddr_in server = {};
+ server.sin_family = AF_INET;
+ server.sin_port = htobe16(Port);
+ inet_aton("127.0.0.1", &server.sin_addr);
+
+ soft_assert(bind(sock, (struct sockaddr*)&server, sizeof(server)) == 0);
+
+ puts("KE started");
+ wait_for_nts_ke(c2s, s2c, sabo);
+ puts("KE done");
+
+ puts("NTP listening");
+ serve_ntp_request(sock, c2s, s2c, sabo);
+ puts("NTP replied");
+
+ return 0;
+}
diff --git a/src/timesync/test-nts.c b/src/timesync/test-nts.c
new file mode 100644
index 0000000000..a21778a191
--- /dev/null
+++ b/src/timesync/test-nts.c
@@ -0,0 +1,418 @@
+/* SPDX-License-Identifier: LGPL-2.1-or-later
+ * Copyright © 2026 Trifecta Tech Foundation */
+
+#include <openssl/evp.h>
+
+#include "nts.h"
+#include "nts_crypto.h"
+#include "nts_extfields.h"
+#include "tests.h"
+#include "timesyncd-forward.h"
+
+#pragma GCC diagnostic ignored "-Wmissing-field-initializers"
+#pragma GCC diagnostic ignored "-Wshadow"
+
+/* it's the callers job to ensure bounds are not transgressed */
+#define encode_record_raw(msg, type, data, len) encode_ptr_len_data(msg, type, data, len, 0)
+#define encode_record_raw_ext(msg, type, data, len) encode_ptr_len_data(msg, type, data, len, 1)
+
+static void encode_ptr_len_data(
+ uint8_t **message,
+ uint16_t type,
+ const void *data,
+ uint16_t len,
+ int count_hdr) {
+
+ uint8_t hdr[4] = {
+ type >> 8,
+ type & 0xFF,
+ (len + count_hdr*sizeof(hdr)) >> 8,
+ (len + count_hdr*sizeof(hdr)) & 0xFF,
+ };
+
+ memcpy(*message, hdr, 4);
+ if (len) memcpy(*message+4, data, len);
+ *message += len + 4;
+}
+
+TEST(nts_encoding) {
+ uint8_t buffer[1000];
+ struct NTS_Agreement rec;
+
+ NTS_encode_request(buffer, sizeof buffer, NULL);
+ assert_se(NTS_decode_response(buffer, 1000, &rec) == 0);
+ assert_se(rec.error == NTS_SUCCESS);
+ assert_se(rec.ntp_server == NULL);
+ assert_se(rec.ntp_port == 0);
+ assert_se(rec.cookie[0].data == NULL);
+ assert_se(rec.cookie[0].length == 0);
+ assert_se(rec.aead_id == NTS_AEAD_AES_SIV_CMAC_256);
+
+ uint16_t proto1[] = { NTS_AEAD_AES_SIV_CMAC_256, NTS_AEAD_AES_SIV_CMAC_512, 0 };
+ NTS_encode_request(buffer, sizeof buffer, proto1);
+ assert_se(NTS_decode_response(buffer, 1000, &rec) == 0);
+ assert_se(rec.error == NTS_SUCCESS);
+ assert_se(rec.ntp_server == NULL);
+ assert_se(rec.ntp_port == 0);
+ assert_se(rec.cookie[0].data == NULL);
+ assert_se(rec.cookie[0].length == 0);
+ assert_se(rec.aead_id == NTS_AEAD_AES_SIV_CMAC_256);
+
+ uint16_t proto2[] = { NTS_AEAD_AES_SIV_CMAC_512, NTS_AEAD_AES_SIV_CMAC_256, 0 };
+ NTS_encode_request(buffer, sizeof buffer, proto2);
+ assert_se(NTS_decode_response(buffer, 1000, &rec) == 0);
+ assert_se(rec.error == NTS_SUCCESS);
+ assert_se(rec.ntp_server == NULL);
+ assert_se(rec.ntp_port == 0);
+ assert_se(rec.cookie[0].data == NULL);
+ assert_se(rec.cookie[0].length == 0);
+ assert_se(rec.aead_id == NTS_AEAD_AES_SIV_CMAC_512);
+}
+
+TEST(nts_decoding) {
+ uint8_t buffer[0x10000], *p;
+ struct NTS_Agreement rec;
+
+ /* empty */
+ uint8_t value[2] = {};
+ encode_record_raw((p = buffer, &p), 0, NULL, 0);
+ assert_se(NTS_decode_response(buffer, sizeof buffer, &rec) != 0);
+ assert_se(rec.error == NTS_BAD_RESPONSE);
+
+ /* missing aead */
+ encode_record_raw((p = buffer, &p), 1, &value, 2);
+ encode_record_raw(&p, 0, NULL, 0);
+ assert_se(NTS_decode_response(buffer, sizeof buffer, &rec) != 0);
+ assert_se(rec.error == NTS_BAD_RESPONSE);
+
+ /* missing nextproto */
+ encode_record_raw((p = buffer, &p), 4, (value[1] = 15, &value), 2);
+ encode_record_raw(&p, 0, NULL, 0);
+ assert_se(NTS_decode_response(buffer, sizeof buffer, &rec) != 0);
+ assert_se(rec.error == NTS_BAD_RESPONSE);
+
+ /* invalid nextproto */
+ encode_record_raw((p = buffer, &p), 4, (value[1] = 15, &value), 2);
+ encode_record_raw(&p, 1, (value[1] = 3, &value), 2);
+ encode_record_raw(&p, 0, NULL, 0);
+ assert_se(NTS_decode_response(buffer, sizeof buffer, &rec) != 0);
+ assert_se(rec.error == NTS_NO_PROTOCOL);
+
+ /* invalid aead */
+ encode_record_raw((p = buffer, &p), 1, (value[1] = 0, &value), 2);
+ encode_record_raw(&p, 4, (value[1] = 37, &value), 2);
+ encode_record_raw(&p, 0, NULL, 0);
+ assert_se(NTS_decode_response(buffer, sizeof buffer, &rec) != 0);
+ assert_se(rec.error == NTS_NO_AEAD);
+
+ /* unknown critical record */
+ encode_record_raw((p = buffer, &p), 1, (value[1] = 0, &value), 2);
+ encode_record_raw(&p, 4, (value[1] = 15, &value), 2);
+ encode_record_raw(&p, 0xfe | 0x8000, &value, 2);
+ encode_record_raw(&p, 0, NULL, 0);
+ assert_se(NTS_decode_response(buffer, sizeof buffer, &rec) != 0);
+ assert_se(rec.error == NTS_UNKNOWN_CRIT_RECORD);
+
+ /* error record */
+ encode_record_raw((p = buffer, &p), 1, (value[1] = 0, &value), 2);
+ encode_record_raw(&p, 4, (value[1] = 15, &value), 2);
+ encode_record_raw(&p, 2, (value[1] = 42, &value), 2);
+ encode_record_raw(&p, 0, NULL, 0);
+ assert_se(NTS_decode_response(buffer, sizeof buffer, &rec) != 0);
+ assert_se(rec.error == 42);
+
+ /* warning record */
+ encode_record_raw((p = buffer, &p), 1, (value[1] = 0, &value), 2);
+ encode_record_raw(&p, 4, (value[1] = 15, &value), 2);
+ encode_record_raw(&p, 3, (value[1] = 42, &value), 2);
+ encode_record_raw(&p, 0, NULL, 0);
+ assert_se(NTS_decode_response(buffer, sizeof buffer, &rec) != 0);
+ assert_se(rec.error == NTS_UNEXPECTED_WARNING);
+
+ /* valid */
+ encode_record_raw((p = buffer, &p), 1, (value[1] = 0, &value), 2);
+ encode_record_raw(&p, 5, "COOKIE1", 7);
+ encode_record_raw(&p, 4, (value[1] = 15, &value), 2);
+ encode_record_raw(&p, 5, "COOKIE22", 8);
+ encode_record_raw(&p, 0xee, "unknown", 7);
+ encode_record_raw(&p, 7, (value[1] = 42, &value), 2);
+ encode_record_raw(&p, 5, "COOKIE333", 9);
+ encode_record_raw(&p, 6, "localhost", 9);
+ encode_record_raw(&p, 5, "COOKIE4444", 10);
+ assert_se(NTS_decode_response(buffer, sizeof buffer, &rec) == 0);
+ assert_se(rec.error == NTS_SUCCESS);
+ assert_se(rec.aead_id == 15);
+ assert_se(rec.ntp_port == 42);
+ assert_se(strcmp(rec.ntp_server, "localhost") == 0);
+ assert_se(memcmp(rec.cookie[0].data, "COOKIE1", rec.cookie[0].length) == 0);
+ assert_se(memcmp(rec.cookie[1].data, "COOKIE22", rec.cookie[1].length) == 0);
+ assert_se(memcmp(rec.cookie[2].data, "COOKIE333", rec.cookie[2].length) == 0);
+ assert_se(memcmp(rec.cookie[3].data, "COOKIE4444", rec.cookie[3].length) == 0);
+ assert_se(rec.cookie[4].data == NULL);
+ assert_se(rec.cookie[4].length == 0);
+}
+
+TEST(ntp_field_encoding) {
+ uint8_t buffer[1280];
+
+ uint8_t key[32] = {};
+ uint8_t identifier[32] = {};
+ char cookie[] = "PAD";
+
+ struct NTS_Query nts = {
+ { (uint8_t*)cookie, strlen(cookie) },
+ key,
+ key,
+ *NTS_get_param(NTS_AEAD_AES_SIV_CMAC_256),
+ };
+
+ struct NTS_Receipt rcpt = {};
+ int len = NTS_add_extension_fields(buffer, &nts, &identifier);
+ assert_se(len > 48);
+ assert_se(NTS_parse_extension_fields(buffer, len, &nts, &rcpt));
+
+ assert_se(rcpt.new_cookie->data == NULL);
+ assert_se(memcmp(buffer + 48 + 36 + 4, cookie, strlen(cookie)) == 0);
+ assert_se(strcmp((char*)buffer + 48 + 36 + 4, cookie) == 0);
+
+ for (int i=0; i < len; i++) {
+ zero(rcpt);
+ len = NTS_add_extension_fields(buffer, &nts, &identifier);
+ buffer[i] ^= 0x20;
+ assert_se(!NTS_parse_extension_fields(buffer, len, &nts, &rcpt));
+ }
+
+ zero(rcpt);
+ len = NTS_add_extension_fields(buffer, &nts, &identifier);
+ nts.s2c_key = (uint8_t[32]){ 1, };
+ assert_se(!NTS_parse_extension_fields(buffer, len, &nts, &rcpt));
+}
+
+#if HAVE_OPENSSL
+static void add_encrypted_server_hdr(
+ uint8_t *buffer,
+ uint8_t **p_ptr,
+ struct NTS_Query nts,
+ const char *cookie[],
+ uint8_t *corrupt) {
+
+ uint8_t *af = *p_ptr;
+ uint8_t *pt;
+ /* write nonce */
+ *p_ptr = pt = (uint8_t*)mempcpy(af+8, "123NONCE", 8) + 16;
+ /* write fields */
+ encode_record_raw_ext(p_ptr, 0x0104, "A sharp mind cuts through deceit", 32);
+ for ( ; *cookie; cookie++)
+ encode_record_raw_ext(p_ptr, 0x0204, *cookie, strlen(*cookie));
+
+ /* corrupt a byte */
+ if (corrupt) *corrupt = 0xee;
+
+ /* encrypt fields */
+ EVP_CIPHER *cipher = EVP_CIPHER_fetch(NULL, "AES-128-SIV", NULL);
+ EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
+ int ignore;
+ EVP_EncryptInit_ex(ctx, cipher, NULL, nts.s2c_key, NULL);
+ EVP_EncryptUpdate(ctx, NULL, &ignore, buffer, af - buffer);
+ EVP_EncryptUpdate(ctx, NULL, &ignore, (uint8_t*)"123NONCE", 8);
+ EVP_EncryptUpdate(ctx, pt, &ignore, pt, *p_ptr - pt);
+ EVP_EncryptFinal_ex(ctx, buffer, &ignore);
+ assert_se(ignore == 0);
+ EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_GET_TAG, 16, pt - 16);
+ EVP_CIPHER_CTX_free(ctx);
+ EVP_CIPHER_free(cipher);
+
+ /* set type to 0x404 */
+ memzero(af, 8);
+ af[0] = af[1] = 0x04;
+ /* set overall packet length */
+ af[3] = *p_ptr - af;
+ /* set nonce length */
+ af[5] = 8;
+ /* set ciphertext length */
+ af[7] = *p_ptr - pt + 16;
+}
+
+TEST(ntp_field_decoding) {
+ uint8_t buffer[1280];
+
+ char cookie[] = "COOKIE", cakey[] = "CAKEY";
+ uint8_t key[32] = {};
+
+ struct NTS_Query nts = {
+ { (uint8_t*)cookie, strlen(cookie) },
+ key,
+ key,
+ *NTS_get_param(NTS_AEAD_AES_SIV_CMAC_256),
+ };
+
+ uint8_t *p = buffer + 48;
+
+ char ident[] = "Silence speaks louder than words";
+ assert(strlen(ident) == 32);
+
+ /* this deliberately breaks padding rules and sneaks an encrypted identifier */
+ encode_record_raw_ext(&p, 0x0104, ident, 32);
+ add_encrypted_server_hdr(buffer, &p, nts, (const char*[]){cookie, cakey, NULL}, NULL);
+
+ struct NTS_Receipt rcpt = {};
+ assert_se(NTS_parse_extension_fields(buffer, p - buffer, &nts, &rcpt));
+
+ assert_se(memcmp(rcpt.identifier, ident, 32) == 0);
+ assert_se(rcpt.new_cookie[0].data != NULL);
+ assert_se(rcpt.new_cookie[0].length >= strlen(cookie));
+ assert_se(memcmp(rcpt.new_cookie[0].data, cookie, strlen(cookie)) == 0);
+ assert_se(rcpt.new_cookie[1].data != NULL);
+ assert_se(rcpt.new_cookie[1].length >= strlen(cakey));
+ assert_se(memcmp(rcpt.new_cookie[1].data, cakey, strlen(cakey)) == 0);
+ assert_se(rcpt.new_cookie[2].data == NULL);
+
+ /* same test but no authentication of uniq id */
+ p = buffer + 48;
+ add_encrypted_server_hdr(buffer, &p, nts, (const char*[]){cookie, NULL}, NULL);
+ encode_record_raw_ext(&p, 0x0104, ident, 32);
+
+ zero(rcpt);
+ assert_se(!NTS_parse_extension_fields(buffer, p - buffer, &nts, &rcpt));
+
+ /* no authentication at all */
+ p = buffer + 48;
+ encode_record_raw(&p, 0x0104, ident, 32);
+ zero(rcpt);
+ assert_se(!NTS_parse_extension_fields(buffer, p - buffer, &nts, &rcpt));
+
+ /* malicious unencrypted field */
+ p = buffer + 48;
+ encode_record_raw_ext(&p, 0x0104, ident, 32);
+ add_encrypted_server_hdr(buffer, &p, nts, (const char*[]){cookie, NULL}, NULL);
+ buffer[48+2] = 0xee;
+ zero(rcpt);
+ assert_se(!NTS_parse_extension_fields(buffer, p - buffer, &nts, &rcpt));
+
+ /* malicious encrypted field */
+ p = buffer + 48;
+ encode_record_raw_ext(&p, 0x0104, ident, 32);
+ /* at p+32 the first plaintext data will be written
+ * so at p+34 is the MSB of the first field length */
+ add_encrypted_server_hdr(buffer, &p, nts, (const char*[]){cookie, NULL}, p+34);
+
+ zero(rcpt);
+ assert_se(!NTS_parse_extension_fields(buffer, p - buffer, &nts, &rcpt));
+}
+#endif
+
+/* appease the gcc static analyzer */
+static const void* nonnull(const void *p) {
+ assert_se(p);
+ return p;
+}
+
+TEST(crypto) {
+ uint8_t key[256];
+ uint8_t enc[100], dec[100];
+ const uint8_t plaintext[] = "attack at down";
+
+ for (unsigned i = 0; i < sizeof(key); i++) key[i] = i * 0x11 & 0xFF;
+
+ const AssociatedData ad[] = {
+ { (uint8_t*)"FNORD", 5 },
+ { (uint8_t*)"XXXXNONCEXXX", 12 },
+ { NULL },
+ };
+
+ /* test roundtrips for all ciphers */
+ for (unsigned id=0; id <= 33; id++) {
+ if (!NTS_get_param(id)) continue;
+ int len = NTS_encrypt(enc, sizeof(enc), plaintext, sizeof(plaintext), ad, nonnull(NTS_get_param(id)), key);
+ assert_se(len > 0);
+ assert_se(NTS_decrypt(dec, sizeof(dec), enc, len, ad, nonnull(NTS_get_param(id)), key) == sizeof(plaintext));
+ assert_se(memcmp(dec, plaintext, sizeof(plaintext)) == 0);
+ }
+
+ /* test in-place decryption for the default cipher */
+ memcpy(enc, plaintext, sizeof(plaintext));
+ int len = NTS_encrypt(enc, sizeof(enc), enc, sizeof(plaintext), ad, nonnull(NTS_get_param(NTS_AEAD_AES_SIV_CMAC_256)), key);
+ assert_se(len == sizeof(plaintext)+16);
+ assert_se(NTS_decrypt(enc, sizeof(enc), enc, len, ad, nonnull(NTS_get_param(NTS_AEAD_AES_SIV_CMAC_256)), key) == sizeof(plaintext));
+ assert_se(memcmp(enc, plaintext, sizeof(plaintext)) == 0);
+
+ /* test known vectors AES_SIV_CMAC_256
+ * we can't test these using Nettle; one way to check that we are on Nettle is currently that it does not
+ * support SIV_CMAC_384
+ */
+ if (NTS_get_param(NTS_AEAD_AES_SIV_CMAC_384)) {
+
+ uint8_t key[] = {
+ 0x7f,0x7e,0x7d,0x7c, 0x7b,0x7a,0x79,0x78, 0x77,0x76,0x75,0x74, 0x73,0x72,0x71,0x70,
+ 0x40,0x41,0x42,0x43, 0x44,0x45,0x46,0x47, 0x48,0x49,0x4a,0x4b, 0x4c,0x4d,0x4e,0x4f,
+ };
+
+
+ uint8_t aad1[] = {
+ 0x00,0x11,0x22,0x33, 0x44,0x55,0x66,0x77, 0x88,0x99,0xaa,0xbb, 0xcc,0xdd,0xee,0xff,
+ 0xde,0xad,0xda,0xda, 0xde,0xad,0xda,0xda, 0xff,0xee,0xdd,0xcc, 0xbb,0xaa,0x99,0x88,
+ 0x77,0x66,0x55,0x44, 0x33,0x22,0x11,0x00,
+ };
+ uint8_t aad2[] = {
+ 0x10,0x20,0x30,0x40, 0x50,0x60,0x70,0x80, 0x90,0xa0,
+ };
+
+ uint8_t nonce[] = {
+ 0x09,0xf9,0x11,0x02, 0x9d,0x74,0xe3,0x5b, 0xd8,0x41,0x56,0xc5, 0x63,0x56,0x88,0xc0,
+ };
+
+ uint8_t pt[] = {
+ 0x74,0x68,0x69,0x73, 0x20,0x69,0x73,0x20, 0x73,0x6f,0x6d,0x65, 0x20,0x70,0x6c,0x61,
+ 0x69,0x6e,0x74,0x65, 0x78,0x74,0x20,0x74, 0x6f,0x20,0x65,0x6e, 0x63,0x72,0x79,0x70,
+ 0x74,0x20,0x75,0x73, 0x69,0x6e,0x67,0x20, 0x53,0x49,0x56,0x2d, 0x41,0x45,0x53
+ };
+ uint8_t ct[] = {
+ 0x7b,0xdb,0x6e,0x3b, 0x43,0x26,0x67,0xeb, 0x06,0xf4,0xd1,0x4b, 0xff,0x2f,0xbd,0x0f,
+ 0xcb,0x90,0x0f,0x2f, 0xdd,0xbe,0x40,0x43, 0x26,0x60,0x19,0x65, 0xc8,0x89,0xbf,0x17,
+ 0xdb,0xa7,0x7c,0xeb, 0x09,0x4f,0xa6,0x63, 0xb7,0xa3,0xf7,0x48, 0xba,0x8a,0xf8,0x29,
+ 0xea,0x64,0xad,0x54, 0x4a,0x27,0x2e,0x9c, 0x48,0x5b,0x62,0xa3, 0xfd,0x5c,0x0d,
+ };
+
+ uint8_t out[sizeof(ct)];
+
+ const AssociatedData info[] = {
+ { aad1, sizeof(aad1) },
+ { aad2, sizeof(aad2) },
+ { nonce, sizeof(nonce) },
+ { NULL }
+ };
+ assert_se(NTS_encrypt(out, sizeof(out), pt, sizeof(pt), info, NTS_get_param(NTS_AEAD_AES_SIV_CMAC_256), key) == sizeof(ct));
+ assert_se(memcmp(out, ct, sizeof(ct)) == 0);
+ }
+
+ /* test known vectors - AES_128_GCM_SIV */
+ if (NTS_get_param(NTS_AEAD_AES_128_GCM_SIV)) {
+ uint8_t key[16] = { 1 };
+ uint8_t nonce[12] = { 3 };
+ uint8_t aad[1] = { 1 };
+ uint8_t pt[8] = { 2 };
+
+ const AssociatedData info[] = {
+ { aad, sizeof(aad) },
+ { nonce, sizeof(nonce) },
+ { NULL }
+ };
+
+ uint8_t ct[] = {
+ 0x1e,0x6d,0xab,0xa3, 0x56,0x69,0xf4,0x27, 0x3b,0x0a,0x1a,0x25, 0x60,0x96,0x9c,0xdf,
+ 0x79,0x0d,0x99,0x75, 0x9a,0xbd,0x15,0x08,
+ };
+
+ uint8_t out[sizeof(ct)];
+
+ assert_se(NTS_encrypt(out, sizeof(out), pt, sizeof(pt), info, NTS_get_param(NTS_AEAD_AES_128_GCM_SIV), key) == sizeof(ct));
+ assert_se(memcmp(out, ct, sizeof(ct)) == 0);
+ }
+}
+
+TEST(keysize) {
+ assert_se(NTS_get_param(NTS_AEAD_AES_SIV_CMAC_256)->key_size == 32);
+ assert_se(NTS_get_param(NTS_AEAD_AES_SIV_CMAC_512)->key_size == 64);
+}
+
+DEFINE_TEST_MAIN(LOG_DEBUG);
diff --git a/src/timesync/timesyncd-bus.c b/src/timesync/timesyncd-bus.c
index b060431f6c..e12803321f 100644
--- a/src/timesync/timesyncd-bus.c
+++ b/src/timesync/timesyncd-bus.c
@@ -158,6 +158,27 @@ static usec_t ntp_ts_to_usec(const struct ntp_ts *ts) {
return (be32toh(ts->sec) - OFFSET_1900_1970) * USEC_PER_SEC + (be32toh(ts->frac) * USEC_PER_SEC) / (usec_t) 0x100000000ULL;
}
+static int property_get_nts_status(
+ sd_bus *bus,
+ const char *path,
+ const char *interface,
+ const char *property,
+ sd_bus_message *reply,
+ void *userdata,
+ sd_bus_error *error) {
+
+ assert(bus);
+ assert(reply);
+
+#if ENABLE_TIMESYNC_NTS
+ NTS_Cookie *cookie = ASSERT_PTR(userdata);
+
+ return sd_bus_message_append(reply, "b", cookie->data != NULL);
+#else
+ return sd_bus_message_append(reply, "b", false);
+#endif
+}
+
static int property_get_ntp_message(
sd_bus *bus,
const char *path,
@@ -212,6 +233,7 @@ static const sd_bus_vtable manager_vtable[] = {
SD_BUS_PROPERTY("SystemNTPServers", "as", property_get_servers, offsetof(Manager, system_servers), SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
SD_BUS_PROPERTY("RuntimeNTPServers", "as", property_get_servers, offsetof(Manager, runtime_servers), SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
SD_BUS_PROPERTY("FallbackNTPServers", "as", property_get_servers, offsetof(Manager, fallback_servers), SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
+ SD_BUS_PROPERTY("NTSKeyExchangeServers", "as", property_get_servers, offsetof(Manager, nts_ke_servers), SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
SD_BUS_PROPERTY("ServerName", "s", property_get_current_server_name, offsetof(Manager, current_server_name), 0),
SD_BUS_PROPERTY("ServerAddress", "(iay)", property_get_current_server_address, offsetof(Manager, current_server_address), 0),
SD_BUS_PROPERTY("RootDistanceMaxUSec", "t", bus_property_get_usec, offsetof(Manager, root_distance_max_usec), SD_BUS_VTABLE_PROPERTY_CONST),
@@ -220,6 +242,11 @@ static const sd_bus_vtable manager_vtable[] = {
SD_BUS_PROPERTY("PollIntervalUSec", "t", bus_property_get_usec, offsetof(Manager, poll_interval_usec), 0),
SD_BUS_PROPERTY("NTPMessage", "(uuuuittayttttbtt)", property_get_ntp_message, 0, SD_BUS_VTABLE_PROPERTY_EMITS_CHANGE),
SD_BUS_PROPERTY("Frequency", "x", NULL, offsetof(Manager, drift_freq), 0),
+#if ENABLE_TIMESYNC_NTS
+ SD_BUS_PROPERTY("SecureTime", "b", property_get_nts_status, offsetof(Manager, nts_cookies), 0),
+#else
+ SD_BUS_PROPERTY("SecureTime", "b", property_get_nts_status, offsetof(Manager, current_server_name), 0),
+#endif
SD_BUS_METHOD_WITH_ARGS("SetRuntimeNTPServers",
SD_BUS_ARGS("as", runtime_servers),
diff --git a/src/timesync/timesyncd-conf.c b/src/timesync/timesyncd-conf.c
index a273c0af9f..602b6996a8 100644
--- a/src/timesync/timesyncd-conf.c
+++ b/src/timesync/timesyncd-conf.c
@@ -11,16 +11,24 @@
#include "timesyncd-server.h"
int manager_parse_server_string(Manager *m, ServerType type, const char *string) {
- ServerName *first;
+ ServerName *first = NULL;
int r;
assert(m);
assert(string);
- first = type == SERVER_FALLBACK ? m->fallback_servers : m->system_servers;
-
- if (type == SERVER_FALLBACK)
- m->fallback_set = true;
+ switch (type) {
+ case SERVER_FALLBACK:
+ m->fallback_set = true;
+ first = m->fallback_servers;
+ break;
+ case SERVER_NTSKE:
+ first = m->nts_ke_servers;
+ break;
+ default:
+ first = m->system_servers;
+ break;
+ }
for (;;) {
_cleanup_free_ char *word = NULL;
@@ -133,5 +141,10 @@ int manager_parse_config_file(Manager *m) {
m->connection_retry_usec = DEFAULT_CONNECTION_RETRY_USEC;
}
+ if (m->nts_keyexchange_timeout_usec < 1 * USEC_PER_SEC) {
+ log_warning("Invalid KeyExchangeTimeoutSec=. Using default value.");
+ m->nts_keyexchange_timeout_usec = NTP_POLL_INTERVAL_MIN_USEC;
+ }
+
return r;
}
diff --git a/src/timesync/timesyncd-gperf.gperf b/src/timesync/timesyncd-gperf.gperf
index d1f18e9b6f..7ceb1d0d39 100644
--- a/src/timesync/timesyncd-gperf.gperf
+++ b/src/timesync/timesyncd-gperf.gperf
@@ -22,10 +22,13 @@ struct ConfigPerfItem;
%includes
%%
Time.NTP, config_parse_servers, SERVER_SYSTEM, 0
+Time.NTS, config_parse_servers, SERVER_NTSKE, 0
Time.Servers, config_parse_servers, SERVER_SYSTEM, 0
Time.FallbackNTP, config_parse_servers, SERVER_FALLBACK, 0
+Time.SecureServers, config_parse_servers, SERVER_NTSKE, 0
Time.RootDistanceMaxSec, config_parse_sec, 0, offsetof(Manager, root_distance_max_usec)
Time.PollIntervalMinSec, config_parse_sec, 0, offsetof(Manager, poll_interval_min_usec)
Time.PollIntervalMaxSec, config_parse_sec, 0, offsetof(Manager, poll_interval_max_usec)
Time.ConnectionRetrySec, config_parse_sec, 0, offsetof(Manager, connection_retry_usec)
Time.SaveIntervalSec, config_parse_sec, 0, offsetof(Manager, save_time_interval_usec)
+Time.KeyExchangeTimeoutSec, config_parse_sec, 0, offsetof(Manager, nts_keyexchange_timeout_usec)
diff --git a/src/timesync/timesyncd-manager.c b/src/timesync/timesyncd-manager.c
index 79a9a629c1..67a6eab6cf 100644
--- a/src/timesync/timesyncd-manager.c
+++ b/src/timesync/timesyncd-manager.c
@@ -27,6 +27,10 @@
#include "log.h"
#include "logarithm.h"
#include "network-util.h"
+#if ENABLE_TIMESYNC_NTS
+#include "nts.h"
+#include "nts_extfields.h"
+#endif
#include "random-util.h"
#include "ratelimit.h"
#include "resolve-private.h"
@@ -67,6 +71,12 @@ static int manager_clock_watch_setup(Manager *m);
static int manager_listen_setup(Manager *m);
static void manager_listen_stop(Manager *m);
static int manager_save_time_and_rearm(Manager *m, usec_t t);
+static int manager_resolve_handler(sd_resolve_query *q, int ret, const struct addrinfo *ai, Manager *m);
+#if ENABLE_TIMESYNC_NTS
+static int manager_nts_obtain_agreement(sd_event_source *source, int fd, uint32_t revents, void *userdata);
+static int manager_nts_handshake_setup(Manager *m);
+static void manager_flush_cookies(Manager *m);
+#endif
static double ntp_ts_short_to_d(const struct ntp_ts_short *ts) {
return be16toh(ts->sec) + (be16toh(ts->frac) / 65536.0);
@@ -80,6 +90,14 @@ static double ts_to_d(const struct timespec *ts) {
return ts->tv_sec + (1.0e-9 * ts->tv_nsec);
}
+#if ENABLE_TIMESYNC_NTS
+static void swap_cookies(NTS_Cookie *a, NTS_Cookie *b) {
+ NTS_Cookie tmp = *a;
+ *a = *b;
+ *b = tmp;
+}
+#endif
+
static int manager_timeout(sd_event_source *source, usec_t usec, void *userdata) {
_cleanup_free_ char *pretty = NULL;
Manager *m = ASSERT_PTR(userdata);
@@ -95,17 +113,21 @@ static int manager_timeout(sd_event_source *source, usec_t usec, void *userdata)
static int manager_send_request(Manager *m) {
_cleanup_free_ char *pretty = NULL;
- struct ntp_msg ntpmsg = {
- /*
- * "The client initializes the NTP message header, sends the request
- * to the server, and strips the time of day from the Transmit
- * Timestamp field of the reply. For this purpose, all the NTP
- * header fields are set to 0, except the Mode, VN, and optional
- * Transmit Timestamp fields."
- */
- .field = NTP_FIELD(0, 4, NTP_MODE_CLIENT),
+
+ union ntp_packet packet = {
+ .ntpmsg = (struct ntp_msg) {
+ /*
+ * "The client initializes the NTP message header, sends the request
+ * to the server, and strips the time of day from the Transmit
+ * Timestamp field of the reply. For this purpose, all the NTP
+ * header fields are set to 0, except the Mode, VN, and optional
+ * Transmit Timestamp fields."
+ */
+ .field = NTP_FIELD(0, 4, NTP_MODE_CLIENT),
+ }
};
- ssize_t len;
+
+ ssize_t packet_len = sizeof(struct ntp_msg);
int r;
assert(m);
@@ -114,19 +136,80 @@ static int manager_send_request(Manager *m) {
m->event_timeout = sd_event_source_unref(m->event_timeout);
+#if ENABLE_TIMESYNC_NTS
+ /* If we are using NTS and out of cookies, we must first make an TLS
+ * connection to perform key extractions and obtain cookies
+ */
+ if (m->nts_missing_cookies >= ELEMENTSOF(m->nts_cookies)) {
+ log_warning("Out of cookies for NTS server %s", m->current_server_name->string);
+ server_name_flush_addresses(m->current_server_name);
+ manager_flush_cookies(m);
+ if (m->talking)
+ /* We have been contacting a time server, let's find that one again */
+ m->current_server_name = NULL;
+ return manager_connect(m);
+ }
+#endif
+
r = manager_listen_setup(m);
if (r < 0) {
log_warning_errno(r, "Failed to set up connection socket: %m");
return manager_connect(m);
}
+#if ENABLE_TIMESYNC_NTS
/*
- * Generate a random number as transmit timestamp, to ensure we get
- * a full 64 bits of entropy to make it hard for off-path attackers
- * to inject random time to us.
+ * Add NTS extension fields if NTS is supported for this NTP time source
*/
- random_bytes(&m->request_nonce, sizeof(m->request_nonce));
- ntpmsg.trans_time = m->request_nonce;
+ if (m->nts_cookies->data) {
+ /* Select an arbitrary cookie to use, to keep cookies fresh.
+ * This has the added benefit to detect NTS servers that try to sequence cookies.
+ * We re-use a byte from the identifier, since this information does not need to be
+ * hidden (it is enough that it is unpredictable).
+ */
+ int num_cookies = ELEMENTSOF(m->nts_cookies) - m->nts_missing_cookies;
+ int randidx = m->nts_identifier[0] % num_cookies;
+ NTS_Cookie *bottom_cookie = &m->nts_cookies[num_cookies-1];
+ swap_cookies(bottom_cookie, &m->nts_cookies[randidx]);
+ assert(bottom_cookie->data);
+
+ packet_len = NTS_add_extension_fields(
+ packet.raw_data,
+ &(NTS_Query) {
+ .cookie = *bottom_cookie,
+ .c2s_key = m->nts_keys.c2s,
+ .s2c_key = m->nts_keys.s2c,
+ .cipher = m->nts_aead,
+ /* note: we only ever request 1 cookie if we are short; some routers
+ * don't like overly long NTP packets and might actually drop them,
+ * which would only exacerbate the problem. If we have < 50% avg packet
+ * loss, this will be enough to keep the cookie reservoir filled
+ */
+ .extra_cookies = m->nts_missing_cookies > 0,
+ },
+ &m->nts_identifier);
+
+ /* Consume and invalidate the cookie, even in case of an error. */
+ memzero(bottom_cookie->data, bottom_cookie->length);
+ m->nts_missing_cookies++;
+
+ if (packet_len <= (int)sizeof(struct ntp_msg)) {
+ log_error_errno(SYNTHETIC_ERRNO(EINVAL), "Failed to encode extension fields");
+ return -EINVAL;
+ }
+ } else {
+#else
+ {
+#endif
+ /*
+ * Generate a random number as transmit timestamp, to ensure we get
+ * a full 64 bits of entropy to make it hard for off-path attackers
+ * to inject random time to us.
+ * In NTS mode, this is handled through the unique identifier.
+ */
+ random_bytes(&m->request_nonce, sizeof(m->request_nonce));
+ packet.ntpmsg.trans_time = m->request_nonce;
+ }
server_address_pretty(m->current_server_address, &pretty);
@@ -137,8 +220,8 @@ static int manager_send_request(Manager *m) {
assert_se(clock_gettime(CLOCK_BOOTTIME, &m->trans_time_mon) >= 0);
assert_se(clock_gettime(CLOCK_REALTIME, &m->trans_time) >= 0);
- len = sendto(m->server_socket, &ntpmsg, sizeof(ntpmsg), MSG_DONTWAIT, &m->current_server_address->sockaddr.sa, m->current_server_address->socklen);
- if (len == sizeof(ntpmsg)) {
+ ssize_t sent = sendto(m->server_socket, &packet.ntpmsg, packet_len, MSG_DONTWAIT, &m->current_server_address->sockaddr.sa, m->current_server_address->socklen);
+ if (sent == packet_len) {
m->pending = true;
log_debug("Sent NTP request to %s (%s).", strna(pretty), m->current_server_name->string);
} else {
@@ -393,11 +476,11 @@ static void manager_adjust_poll(Manager *m, double offset, bool spike) {
static int manager_receive_response(sd_event_source *source, int fd, uint32_t revents, void *userdata) {
Manager *m = ASSERT_PTR(userdata);
- struct ntp_msg ntpmsg;
+ union ntp_packet packet;
struct iovec iov = {
- .iov_base = &ntpmsg,
- .iov_len = sizeof(ntpmsg),
+ .iov_base = &packet,
+ .iov_len = sizeof(packet),
};
/* This needs to be initialized with zero. See #20741.
* The issue is fixed on glibc-2.35 (8fba672472ae0055387e9315fc2eddfa6775ca79). */
@@ -428,6 +511,7 @@ static int manager_receive_response(sd_event_source *source, int fd, uint32_t re
len = recvmsg_safe(fd, &msghdr, MSG_DONTWAIT);
if (ERRNO_IS_NEG_TRANSIENT(len))
return 0;
+
if (len < 0) {
log_warning_errno(len, "Error receiving message, disconnecting: %s",
len == -ECHRNG ? "got truncated control data" :
@@ -460,11 +544,69 @@ static int manager_receive_response(sd_event_source *source, int fd, uint32_t re
m->missed_replies = 0;
- /* check the transmit request nonce was properly returned in the origin_time field */
- if (ntpmsg.origin_time.sec != m->request_nonce.sec || ntpmsg.origin_time.frac != m->request_nonce.frac) {
- log_debug("Invalid reply; not our transmit time. Ignoring.");
- return 0;
- }
+ struct ntp_msg ntpmsg = packet.ntpmsg;
+
+ const char *security = "insecure";
+#if ENABLE_TIMESYNC_NTS
+ if (m->nts_cookies->data) {
+ /* verify the NTS extension fields and unique identifier */
+ NTS_Receipt rcpt = {};
+ r = NTS_parse_extension_fields(packet.raw_data, iov.iov_len,
+ &(NTS_Query) {
+ .cookie = *m->nts_cookies,
+ .c2s_key = m->nts_keys.c2s,
+ .s2c_key = m->nts_keys.s2c,
+ .cipher = m->nts_aead,
+ },
+ &rcpt);
+ if (r <= 0) {
+ log_debug("NTS verification for %s failed! Ignoring.", m->current_server_name->string);
+ return 0;
+ }
+
+ if (!rcpt.identifier || memcmp(m->nts_identifier, *rcpt.identifier, sizeof(m->nts_identifier)) != 0) {
+ log_debug("NTS packet had an invalid unique identifier. Ignoring.");
+ return 0;
+ }
+
+ /* invalidate the identifier to prevent replays */
+ m->nts_identifier[0] ^= 0xFF;
+
+ assert(m->nts_missing_cookies <= ELEMENTSOF(m->nts_cookies));
+
+ if (!rcpt.new_cookie->data)
+ log_warning("Server did not return a new cookie.");
+ else if (m->nts_missing_cookies == 0)
+ log_error("A valid NTS packet was received but we were not missing any cookies. Please report this bug.");
+ else FOREACH_ELEMENT(fresh_cookie, rcpt.new_cookie) {
+ if (m->nts_missing_cookies == 0 || fresh_cookie->data == NULL)
+ break;
+
+ NTS_Cookie *cookie = &m->nts_cookies[ELEMENTSOF(m->nts_cookies) - m->nts_missing_cookies];
+ /* re-use the existing storage if possible */
+ if (fresh_cookie->length > cookie->length) {
+ log_info("Server returned a fresh cookie that was longer than the original one.");
+ mfree(cookie->data);
+ cookie->length = 0;
+ cookie->data = malloc(fresh_cookie->length);
+ if (cookie->data == NULL)
+ return -ENOMEM;
+ }
+ memcpy(cookie->data, fresh_cookie->data, fresh_cookie->length);
+ cookie->length = fresh_cookie->length;
+ m->nts_missing_cookies--;
+ }
+
+ log_debug("NTP packet is authentic.");
+ security = "secure";
+ } else
+#endif
+ /* check the transmit request nonce was properly returned in the origin_time field */
+ if (ntpmsg.origin_time.sec != m->request_nonce.sec || ntpmsg.origin_time.frac != m->request_nonce.frac) {
+ log_debug("Invalid reply; not our transmit time. Ignoring.");
+ return 0;
+ }
+
m->event_timeout = sd_event_source_unref(m->event_timeout);
@@ -611,7 +753,7 @@ static int manager_receive_response(sd_event_source *source, int fd, uint32_t re
(void) server_address_pretty(m->current_server_address, &pretty);
- log_info("Contacted time server %s (%s).", strna(pretty), m->current_server_name->string);
+ log_info("Contacted %s time server %s (%s).", security, strna(pretty), m->current_server_name->string);
(void) sd_notifyf(false, "STATUS=Contacted time server %s (%s).", strna(pretty), m->current_server_name->string);
}
@@ -693,7 +835,14 @@ static int manager_begin(Manager *m) {
if (r < 0)
return r;
+#if ENABLE_TIMESYNC_NTS
+ if (m->nts_missing_cookies >= ELEMENTSOF(m->nts_cookies))
+ return manager_nts_handshake_setup(m);
+ else
+ return manager_send_request(m);
+#else
return manager_send_request(m);
+#endif
}
void manager_set_server_name(Manager *m, ServerName *n) {
@@ -817,31 +966,35 @@ int manager_connect(Manager *m) {
ServerName *f;
bool restart = true;
- /* Our current server name list is exhausted,
- * let's find the next one to iterate. First we try the runtime list, then the system list,
- * then the link list. After having processed the link list we jump back to the system list
- * if no runtime server list.
- * However, if all lists are empty, we change to the fallback list. */
- if (!m->current_server_name || m->current_server_name->type == SERVER_LINK) {
- f = m->runtime_servers;
- if (!f)
- f = m->system_servers;
- if (!f)
- f = m->link_servers;
- } else {
- f = m->link_servers;
- if (f)
- restart = false;
- else {
+ /* Our current server name list is exhausted, let's find the next one to iterate.
+ * There are two scenarios: secure time servers are configured or not.
+ * - If there is a NTSKE list: try only that.
+ * - Otherwise, we try the runtime list first, then the system list, then the link list.
+ * After having processed the link list we jump back to the system list if no
+ * runtime server list. If all lists are empty, we change to the fallback list. */
+ if (m->nts_ke_servers) {
+ f = m->nts_ke_servers;
+ } else {
+ if (!m->current_server_name || m->current_server_name->type == SERVER_LINK) {
f = m->runtime_servers;
if (!f)
f = m->system_servers;
+ if (!f)
+ f = m->link_servers;
+ } else {
+ f = m->link_servers;
+ if (f)
+ restart = false;
+ else {
+ f = m->runtime_servers;
+ if (!f)
+ f = m->system_servers;
+ }
}
+ if (!f)
+ f = m->fallback_servers;
}
- if (!f)
- f = m->fallback_servers;
-
if (!f) {
manager_set_server_name(m, NULL);
log_debug("No server found.");
@@ -877,13 +1030,30 @@ int manager_connect(Manager *m) {
log_debug("Resolving %s...", m->current_server_name->string);
+ bool nts = m->current_server_name->type == SERVER_NTSKE;
+
struct addrinfo hints = {
.ai_flags = AI_NUMERICSERV|AI_ADDRCONFIG,
- .ai_socktype = SOCK_DGRAM,
+ .ai_socktype = nts ? SOCK_STREAM : SOCK_DGRAM,
.ai_family = socket_ipv6_is_supported() ? AF_UNSPEC : AF_INET,
};
- r = resolve_getaddrinfo(m->resolve, &m->resolve_query, m->current_server_name->string, "123", &hints, manager_resolve_handler, NULL, m);
+#if ENABLE_TIMESYNC_NTS
+ /* For NTS, we first connect to the NTSKE */
+ const char *port = nts ? "4460" : "123";
+
+ m->nts_missing_cookies = nts ? ELEMENTSOF(m->nts_cookies) : 0;
+#else
+ const char *port = "123";
+
+ if (nts) {
+ log_error("timesyncd was not compiled with NTS support");
+ return 0;
+ }
+#endif
+
+ r = resolve_getaddrinfo(m->resolve, &m->resolve_query, m->current_server_name->string, port, &hints, manager_resolve_handler, NULL, m);
+
if (r < 0)
return log_error_errno(r, "Failed to create resolver: %m");
@@ -910,6 +1080,12 @@ void manager_disconnect(Manager *m) {
m->event_timeout = sd_event_source_unref(m->event_timeout);
+#if ENABLE_TIMESYNC_NTS
+ m->nts_timeout = sd_event_source_unref(m->nts_timeout);
+
+ NTS_TLS_close(&m->nts_handshake);
+#endif
+
(void) sd_notify(false, "STATUS=Idle.");
}
@@ -928,6 +1104,10 @@ void manager_flush_server_names(Manager *m, ServerType t) {
while (m->fallback_servers)
server_name_free(m->fallback_servers);
+ if (t == SERVER_NTSKE)
+ while (m->nts_ke_servers)
+ server_name_free(m->nts_ke_servers);
+
if (t == SERVER_RUNTIME)
manager_flush_runtime_servers(m);
}
@@ -948,6 +1128,7 @@ Manager* manager_free(Manager *m) {
manager_flush_server_names(m, SERVER_LINK);
manager_flush_server_names(m, SERVER_RUNTIME);
manager_flush_server_names(m, SERVER_FALLBACK);
+ manager_flush_server_names(m, SERVER_NTSKE);
sd_event_source_unref(m->event_retry);
@@ -965,6 +1146,10 @@ Manager* manager_free(Manager *m) {
hashmap_free(m->polkit_registry);
+#if ENABLE_TIMESYNC_NTS
+ manager_flush_cookies(m);
+#endif
+
return mfree(m);
}
@@ -1117,6 +1302,7 @@ int manager_new(Manager **ret) {
.root_distance_max_usec = NTP_ROOT_DISTANCE_MAX_USEC,
.poll_interval_min_usec = NTP_POLL_INTERVAL_MIN_USEC,
.poll_interval_max_usec = NTP_POLL_INTERVAL_MAX_USEC,
+ .nts_keyexchange_timeout_usec = NTP_POLL_INTERVAL_MIN_USEC,
.connection_retry_usec = DEFAULT_CONNECTION_RETRY_USEC,
@@ -1227,6 +1413,7 @@ static int manager_save_time_and_rearm(Manager *m, usec_t t) {
static const char* ntp_server_property_name[_SERVER_TYPE_MAX] = {
[SERVER_SYSTEM] = "SystemNTPServers",
[SERVER_FALLBACK] = "FallbackNTPServers",
+ [SERVER_NTSKE] = "NTSKeyExchangeServers",
[SERVER_LINK] = "LinkNTPServers",
[SERVER_RUNTIME] = "RuntimeNTPServers",
};
@@ -1291,3 +1478,282 @@ int bus_manager_emit_ntp_server_changed(Manager *m) {
return 1;
}
+
+#if ENABLE_TIMESYNC_NTS
+
+static int manager_nts_handshake_timeout(sd_event_source *source, usec_t usec, void *userdata) {
+ _cleanup_free_ char *pretty = NULL;
+ Manager *m = ASSERT_PTR(userdata);
+
+ assert(m->current_server_name);
+ assert(m->current_server_address);
+
+ m->event_timeout = sd_event_source_unref(m->event_timeout);
+
+ NTS_TLS_close(&m->nts_handshake);
+
+ manager_listen_stop(m);
+
+ server_address_pretty(m->current_server_address, &pretty);
+ log_info("Timed out during key exchange with %s (%s).", strna(pretty), m->current_server_name->string);
+
+ return manager_connect(m);
+}
+
+static int manager_nts_handshake_setup(Manager *m) {
+ int r;
+
+ assert(m);
+
+ if (m->server_socket >= 0)
+ return 0;
+
+ assert(!m->event_receive);
+ assert(m->current_server_address);
+
+ struct sockaddr *addr = &m->current_server_address->sockaddr.sa;
+
+ m->server_socket = socket(addr->sa_family, SOCK_STREAM | SOCK_CLOEXEC | SOCK_NONBLOCK, 0);
+ if (m->server_socket < 0)
+ return -errno;
+
+ m->nts_handshake_state = NTS_HANDSHAKE_CONNECTING;
+
+ m->nts_timeout = sd_event_source_unref(m->nts_timeout);
+
+ r = sd_event_add_time_relative(
+ m->event,
+ &m->nts_timeout,
+ CLOCK_BOOTTIME,
+ m->nts_keyexchange_timeout_usec, 0,
+ manager_nts_handshake_timeout, m);
+ if (r < 0)
+ return log_error_errno(r, "Failed to arm NTS key exchange timeout timer: %m");
+
+ r = connect(m->server_socket, addr, m->current_server_address->socklen);
+ if (r < 0 && errno != EINPROGRESS)
+ return -errno;
+
+ return sd_event_add_io(m->event, &m->event_receive, m->server_socket, EPOLLIN|EPOLLOUT, manager_nts_obtain_agreement, m);
+}
+
+static int manager_nts_obtain_agreement(sd_event_source *source, int fd, uint32_t revents, void *userdata) {
+ Manager *m = ASSERT_PTR(userdata);
+
+ NTS_Agreement NTS;
+ int r;
+
+ if (revents & (EPOLLHUP|EPOLLERR)) {
+ NTS_TLS_close(&m->nts_handshake);
+ m->nts_timeout = sd_event_source_unref(m->nts_timeout);
+ log_warning("Server connection returned error.");
+ return manager_connect(m);
+ }
+
+ switch (m->nts_handshake_state) {
+ struct sockaddr *addr;
+ uint8_t *bufp;
+ int size;
+
+ case NTS_HANDSHAKE_CONNECTING:
+ addr = &m->current_server_address->sockaddr.sa;
+
+ r = connect(m->server_socket, addr, m->current_server_address->socklen);
+ if (r < 0) {
+ if (errno == EALREADY)
+ return 1;
+ else if (errno != EISCONN)
+ return -errno;
+ }
+
+ r = setsockopt_int(m->server_socket, SOL_SOCKET, SO_TIMESTAMPNS, true);
+ if (r < 0)
+ return r;
+
+ (void) socket_set_option(m->server_socket, addr->sa_family, IP_TOS, IPV6_TCLASS, IPTOS_DSCP_EF);
+
+ log_debug("Performing key exchange with %s", m->current_server_name->string);
+
+ assert(!m->nts_handshake);
+ m->nts_handshake = NTS_TLS_setup(m->current_server_name->string, m->server_socket);
+ if (!m->nts_handshake)
+ return -ENOMEM;
+
+ m->nts_handshake_state = NTS_HANDSHAKE_TLS;
+ _fallthrough_;
+
+ case NTS_HANDSHAKE_TLS:
+ r = NTS_TLS_handshake(m->nts_handshake);
+ if (r == 0)
+ return 1;
+
+ if (r < 0) {
+ log_warning("Could not set up TLS session with server");
+ NTS_TLS_close(&m->nts_handshake);
+ m->nts_timeout = sd_event_source_unref(m->nts_timeout);
+ return manager_connect(m);
+ }
+
+ /* tls handshake is established, prepare the request */
+ NTS_AEADAlgorithmType prefs[6] = {
+ NTS_AEAD_AES_128_GCM_SIV,
+ NTS_AEAD_AES_256_GCM_SIV,
+ NTS_AEAD_AES_SIV_CMAC_256,
+ NTS_AEAD_AES_SIV_CMAC_384,
+ NTS_AEAD_AES_SIV_CMAC_512,
+ 0
+ };
+
+ int prefs_len = 0;
+ FOREACH_ELEMENT(algo_type, prefs)
+ /* filter out AEAD's that may not be supported */
+ if (NTS_get_param(*algo_type))
+ prefs[prefs_len++] = *algo_type;
+
+ prefs[prefs_len] = 0;
+
+ r = NTS_encode_request(m->nts_packet_buffer, sizeof(m->nts_packet_buffer), prefs);
+ if (r < 0) {
+ log_error_errno(r, "NTS request encoding failed: %m");
+ NTS_TLS_close(&m->nts_handshake);
+ m->nts_timeout = sd_event_source_unref(m->nts_timeout);
+ return manager_connect(m);
+ }
+ m->nts_request_size = r;
+ m->nts_bytes_processed = 0;
+ assert(m->nts_request_size <= (int)sizeof(m->nts_packet_buffer));
+
+ m->nts_handshake_state = NTS_HANDSHAKE_TX;
+ _fallthrough_;
+
+ case NTS_HANDSHAKE_TX:
+ size = m->nts_request_size - m->nts_bytes_processed;
+ bufp = m->nts_packet_buffer + m->nts_bytes_processed;
+
+ r = NTS_TLS_write(m->nts_handshake, bufp, size);
+ assert(r <= size);
+
+ if (r < 0) {
+ log_warning("Error sending NTS key request");
+ NTS_TLS_close(&m->nts_handshake);
+ m->nts_timeout = sd_event_source_unref(m->nts_timeout);
+ return manager_connect(m);
+ } else if (r < size) {
+ m->nts_bytes_processed += r;
+ return 1;
+ }
+
+ /* NTS request sent, read the reply */
+ m->nts_bytes_processed = 0;
+ m->nts_handshake_state = NTS_HANDSHAKE_RX;
+ _fallthrough_;
+
+ case NTS_HANDSHAKE_RX:
+ size = sizeof(m->nts_packet_buffer) - m->nts_bytes_processed;
+ bufp = m->nts_packet_buffer + m->nts_bytes_processed;
+ r = NTS_TLS_read(m->nts_handshake, bufp, size);
+ assert(r <= size);
+
+ if (r < 0) {
+ log_warning("Error receiving NTS key response");
+ NTS_TLS_close(&m->nts_handshake);
+ m->nts_timeout = sd_event_source_unref(m->nts_timeout);
+ return manager_connect(m);
+ }
+
+ m->nts_bytes_processed += r;
+
+ r = NTS_decode_response(m->nts_packet_buffer, m->nts_bytes_processed, &NTS);
+ if (r < 0) {
+ if (NTS.error == NTS_INSUFFICIENT_DATA)
+ return 1;
+
+ log_warning("NTS Error: %s", NTS_error_string(NTS.error));
+ NTS_TLS_close(&m->nts_handshake);
+ m->nts_timeout = sd_event_source_unref(m->nts_timeout);
+ return manager_connect(m);
+ }
+
+ /* end of interactive part of the NTS handshake */
+ m->nts_handshake_state = _NTS_HANDSHAKE_STATE_INVALID;
+ m->nts_timeout = sd_event_source_unref(m->nts_timeout);
+ break;
+
+ default:
+ assert_not_reached();
+ }
+
+ /* extract keys from TLS session and process the NTS response */
+
+ r = NTS_TLS_extract_keys(
+ m->nts_handshake,
+ NTS.aead_id,
+ m->nts_keys.c2s,
+ m->nts_keys.s2c,
+ MAX_NTS_AEAD_KEY_LEN);
+
+ NTS_TLS_close(&m->nts_handshake);
+ manager_listen_stop(m);
+
+ if (r != 0) {
+ log_error_errno(r, "Key extraction failed: %m");
+ return manager_connect(m);
+ }
+
+ const NTS_AEADParam *param = NTS_get_param(NTS.aead_id);
+ if (!param) {
+ log_warning("NTS server offered unknown AEAD %d", NTS.aead_id);
+ return manager_connect(m);
+ }
+ m->nts_aead = *param;
+
+ const char *hostname = NTS.ntp_server ? NTS.ntp_server : m->current_server_name->string;
+ char port[sizeof("65535")];
+ xsprintf(port, "%u", NTS.ntp_port ? NTS.ntp_port : 123U);
+
+ static_assert(ELEMENTSOF(NTS.cookie) <= ELEMENTSOF(m->nts_cookies), "size mismatch in data structures");
+
+ int num_cookies = 0;
+ FOREACH_ELEMENT(cookie, NTS.cookie)
+ if (cookie->data && cookie->length > 0) {
+ char *copy = malloc(cookie->length);
+ if (copy == NULL)
+ return -ENOMEM;
+
+ mfree(m->nts_cookies[num_cookies].data);
+ m->nts_cookies[num_cookies].data = memcpy(copy, cookie->data, cookie->length);
+ m->nts_cookies[num_cookies].length = cookie->length;
+ num_cookies++;
+ }
+
+ /* An invariant for the manager: there are always > 0 cookies when NTS is enabled */
+ if (num_cookies == 0) {
+ log_warning("NTS server offered no cookies");
+ return manager_connect(m);
+ }
+
+ log_debug("Secured NTP server: %s:%s, %s, %d cookies", hostname, port, m->nts_aead.cipher_name, num_cookies);
+
+ struct addrinfo hints = {
+ .ai_flags = AI_NUMERICSERV|AI_ADDRCONFIG,
+ .ai_socktype = SOCK_DGRAM,
+ .ai_family = socket_ipv6_is_supported() ? AF_UNSPEC : AF_INET,
+ };
+
+ /* Clear the current NTSKE server */
+ server_name_flush_addresses(m->current_server_name);
+
+ r = resolve_getaddrinfo(m->resolve, &m->resolve_query, hostname, port, &hints, manager_resolve_handler, NULL, m);
+ if (r < 0)
+ return log_error_errno(r, "Failed to create resolver: %m");
+
+ m->nts_missing_cookies = ELEMENTSOF(m->nts_cookies) - num_cookies;
+
+ return 1;
+}
+
+static void manager_flush_cookies(Manager *m) {
+ FOREACH_ELEMENT(cookie, m->nts_cookies)
+ cookie->data = mfree(cookie->data);
+}
+#endif
diff --git a/src/timesync/timesyncd-manager.h b/src/timesync/timesyncd-manager.h
index 9966b64ac6..302af96f3e 100644
--- a/src/timesync/timesyncd-manager.h
+++ b/src/timesync/timesyncd-manager.h
@@ -2,10 +2,14 @@
#pragma once
#include "list.h"
+#if ENABLE_TIMESYNC_NTS
+#include "nts.h"
+#endif
#include "ratelimit.h"
#include "time-util.h"
#include "timesyncd-forward.h"
#include "timesyncd-ntp-message.h"
+#include "timesyncd-server.h"
/*
* "A client MUST NOT under any conditions use a poll interval less
@@ -21,6 +25,8 @@
#define DEFAULT_SAVE_TIME_INTERVAL_USEC (60 * USEC_PER_SEC)
+#define MAX_NTS_AEAD_KEY_LEN 64
+
typedef struct Manager {
sd_bus *bus;
sd_event *event;
@@ -30,6 +36,7 @@ typedef struct Manager {
LIST_HEAD(ServerName, link_servers);
LIST_HEAD(ServerName, runtime_servers);
LIST_HEAD(ServerName, fallback_servers);
+ LIST_HEAD(ServerName, nts_ke_servers);
RateLimit ratelimit;
bool exhausted_servers;
@@ -50,13 +57,46 @@ typedef struct Manager {
sd_event_source *event_timeout;
bool talking;
+#if ENABLE_TIMESYNC_NTS
+ /* nts ke */
+ struct NTS_Cookie nts_cookies[8];
+ struct {
+ uint8_t s2c[MAX_NTS_AEAD_KEY_LEN];
+ uint8_t c2s[MAX_NTS_AEAD_KEY_LEN];
+ } nts_keys;
+ struct NTS_AEADParam nts_aead;
+ unsigned nts_missing_cookies;
+ sd_event_source *nts_timeout;
+
+ /* data needed for the handshake part only */
+ NTS_TLS *nts_handshake;
+ uint8_t nts_packet_buffer[1024];
+ int nts_request_size;
+ int nts_bytes_processed;
+
+ enum {
+ NTS_HANDSHAKE_CONNECTING,
+ NTS_HANDSHAKE_TLS,
+ NTS_HANDSHAKE_TX,
+ NTS_HANDSHAKE_RX,
+ _NTS_HANDSHAKE_STATE_MAX,
+ _NTS_HANDSHAKE_STATE_INVALID = -EINVAL,
+ } nts_handshake_state;
+#endif
+ usec_t nts_keyexchange_timeout_usec;
+
/* PolicyKit */
Hashmap *polkit_registry;
/* last sent packet */
struct timespec trans_time_mon;
struct timespec trans_time;
- struct ntp_ts request_nonce;
+ union {
+ /* we use either the transit time (NTP) a unique identifier (NTS)
+ * as a nonce, but not both */
+ struct ntp_ts request_nonce;
+ uint8_t nts_identifier[32];
+ };
usec_t retry_interval;
usec_t connection_retry_usec;
bool pending;
diff --git a/src/timesync/timesyncd-ntp-message.h b/src/timesync/timesyncd-ntp-message.h
index ee6a6d7a77..14892181e2 100644
--- a/src/timesync/timesyncd-ntp-message.h
+++ b/src/timesync/timesyncd-ntp-message.h
@@ -44,3 +44,9 @@ struct ntp_msg {
struct ntp_ts recv_time;
struct ntp_ts trans_time;
} _packed_;
+
+/* the maximum recommended size of a NTP packet for NTS purposes */
+union ntp_packet{
+ struct ntp_msg ntpmsg;
+ uint8_t raw_data[1280];
+} _packed_;
diff --git a/src/timesync/timesyncd-server.c b/src/timesync/timesyncd-server.c
index 431c749c0f..f2eb3ead99 100644
--- a/src/timesync/timesyncd-server.c
+++ b/src/timesync/timesyncd-server.c
@@ -9,6 +9,7 @@
static const char * const server_type_table[_SERVER_TYPE_MAX] = {
[SERVER_SYSTEM] = "system",
[SERVER_FALLBACK] = "fallback",
+ [SERVER_NTSKE] = "ntske",
[SERVER_LINK] = "link",
[SERVER_RUNTIME] = "runtime",
};
@@ -116,6 +117,9 @@ int server_name_new(
case SERVER_RUNTIME:
LIST_APPEND(names, m->runtime_servers, n);
break;
+ case SERVER_NTSKE:
+ LIST_APPEND(names, m->nts_ke_servers, n);
+ break;
default:
assert_not_reached();
}
@@ -154,6 +158,8 @@ ServerName *server_name_free(ServerName *n) {
LIST_REMOVE(names, n->manager->fallback_servers, n);
else if (n->type == SERVER_RUNTIME)
LIST_REMOVE(names, n->manager->runtime_servers, n);
+ else if (n->type == SERVER_NTSKE)
+ LIST_REMOVE(names, n->manager->nts_ke_servers, n);
else
assert_not_reached();
diff --git a/src/timesync/timesyncd-server.h b/src/timesync/timesyncd-server.h
index ba3d9ff183..acf1b04d1a 100644
--- a/src/timesync/timesyncd-server.h
+++ b/src/timesync/timesyncd-server.h
@@ -10,6 +10,7 @@ typedef enum ServerType {
SERVER_FALLBACK,
SERVER_LINK,
SERVER_RUNTIME,
+ SERVER_NTSKE,
_SERVER_TYPE_MAX,
_SERVER_TYPE_INVALID = -EINVAL,
} ServerType;
diff --git a/src/timesync/timesyncd.c b/src/timesync/timesyncd.c
index 96d0dd5c2b..b08f709209 100644
--- a/src/timesync/timesyncd.c
+++ b/src/timesync/timesyncd.c
@@ -21,6 +21,7 @@
#include "network-util.h"
#include "process-util.h"
#include "service-util.h"
+#include "signal-util.h"
#include "timesyncd-bus.h"
#include "timesyncd-conf.h"
#include "timesyncd-manager.h"
@@ -157,6 +158,7 @@ static int run(int argc, char *argv[]) {
return r;
umask(0022);
+ (void) ignore_signals(SIGPIPE);
if (argc != 1)
return log_error_errno(SYNTHETIC_ERRNO(EINVAL), "This program does not take arguments.");
diff --git a/src/timesync/timesyncd.conf.in b/src/timesync/timesyncd.conf.in
index 6ef41cf0c5..97d3e21a40 100644
--- a/src/timesync/timesyncd.conf.in
+++ b/src/timesync/timesyncd.conf.in
@@ -18,9 +18,11 @@
[Time]
#NTP=
+#NTS=
#FallbackNTP={{NTP_SERVERS}}
#RootDistanceMaxSec=5
#PollIntervalMinSec=32
#PollIntervalMaxSec=2048
#ConnectionRetrySec=30
#SaveIntervalSec=60
+#KeyExchangeTimeoutSec=32
diff --git a/test/units/TEST-45-TIMEDATE.sh b/test/units/TEST-45-TIMEDATE.sh
index 820c3ae9f5..999cdd412c 100755
--- a/test/units/TEST-45-TIMEDATE.sh
+++ b/test/units/TEST-45-TIMEDATE.sh
@@ -320,6 +320,28 @@ assert_timesyncd_signal() {
return 1
}
+assert_timesyncd_ntp_message() {
+ local timestamp="${1:?}"
+ local property="NTPMessage"
+ local value="(uuuuittayttttbtt)"
+ local args=(-q --since="$timestamp" -p info -t busctl)
+
+ journalctl --sync
+
+ for _ in {0..9}; do
+ if journalctl "${args[@]}" --grep .; then
+ # Make the found entry in the archived journal, to avoid the following failure:
+ # Journal file /run/log/journal/.../system.journal is truncated, ignoring file.
+ journalctl --rotate
+ [[ "$(journalctl "${args[@]}" -o cat | jq -r ".payload.data[1].$property.type")" == "$value" ]];
+ return 0
+ fi
+
+ sleep .5
+ done
+
+ return 1
+}
assert_networkd_ntp() {
local interface="${1:?}"
local value="${2:?}"
@@ -404,6 +426,10 @@ EOF
[[ "$servers" == 'as 4 "10.0.0.1" "foo" "192.168.99.1" "bar"' ]]
assert_timesyncd_signal "$ts" RuntimeNTPServers "10.0.0.1 foo 192.168.99.1 bar"
+ # SecureTime
+ secure="$(busctl get-property org.freedesktop.timesync1 /org/freedesktop/timesync1 org.freedesktop.timesync1.Manager SecureTime)"
+ [[ "$secure" == "b false" ]]
+
# Cleanup
systemctl stop systemd-networkd systemd-timesyncd
rm -f /run/systemd/network/ntp99.*
@@ -453,6 +479,201 @@ LOCAL"
fi
}
+install_mock_certificate() {
+ local servername="$1"
+ local out="$2"
+ local workdir="/tmp"
+
+ # this also installs a fake CA since we don't allow self-signed certificates
+ openssl genrsa -out "$workdir"/CA.key 2048
+ openssl req -x509 -noenc -key "$workdir"/CA.key -subj "/CN=$servername" -out "$workdir"/CA.crt
+ openssl genrsa -out "$workdir"/server.key 2048
+ openssl req -new -key "$workdir"/server.key -subj "/CN=$servername" -out "$workdir"/server.csr
+ openssl req -CA "$workdir"/CA.crt -in "$workdir"/server.csr -CAkey "$workdir"/CA.key -out "$workdir"/server.crt
+ cat "$workdir"/CA.crt >> "$workdir"/server.crt
+ cp "$workdir"/CA.crt "$out"
+ openssl rehash
+}
+
+save_netif_state() {
+ if [[ -f /run/systemd/netif/state ]]; then
+ mv /run/systemd/netif/state /tmp/netif.state.bak
+ else
+ rm -f /tmp/netif.state.bak
+ fi
+}
+
+restore_netif_state() {
+ if [[ -f /tmp/netif.state.bak ]]; then
+ mv /tmp/netif.state.bak /run/systemd/netif/state
+ else
+ rm -f /run/systemd/netif/state
+ fi
+}
+
+testcase_nts() {
+ if systemd-detect-virt -cq; then
+ echo "This test case requires a VM, skipping..."
+ return 0
+ fi
+
+ if ! [ -x /usr/lib/systemd/tests/unit-tests/manual/test-nts-mockserver ]; then
+ echo "NTS not available, skipping..."
+ return 0
+ fi
+
+ local FAKEROOT_CA=/etc/ssl/certs/CA_dummy_cert.crt
+
+ save_netif_state
+
+ # shellcheck disable=SC2329
+ cleanup() {
+ rm -f "$FAKEROOT_CA"
+ rm -rf /run/systemd/timesyncd.conf.d
+ systemctl stop systemd-timesyncd busctl-monitor.service nts-mock.service
+ restore_netif_state
+ }
+
+ trap cleanup RETURN ERR
+
+ # configure a few NTP servers to test that they are ignored if NTS support is enabled
+ local mock_server="localhost"
+
+ mkdir -p /run/systemd/timesyncd.conf.d
+ cat >/run/systemd/timesyncd.conf.d/timesyncd.conf <<EOF
+[Time]
+NTP=debian.pool.ntp.org
+NTS=does.not.exist $mock_server not.found
+FallbackNTP=0.debian.pool.ntp.org
+EOF
+ systemctl daemon-reload
+
+ # trick timesyncd (or rather, its call to "network_is_online()") into thinking
+ # that there is a network; otherwise timesyncd will never attempt to sync time
+ echo "partial" > /run/systemd/netif/state
+
+ install_mock_certificate "$mock_server" "$FAKEROOT_CA"
+
+ systemctl unmask systemd-timesyncd
+ systemctl restart systemd-timesyncd
+ timedatectl set-ntp false
+
+ # this will handle exactly one KE and one NTP request
+ systemd-run --unit=nts-mock.service --working-directory=/tmp --remain-after-exit \
+ /usr/lib/systemd/tests/unit-tests/manual/test-nts-mockserver
+
+ systemd-run --unit busctl-monitor.service --service-type=notify \
+ busctl monitor --json=short --match="type=signal,sender=org.freedesktop.timesync1,member=PropertiesChanged,path=/org/freedesktop/timesync1"
+
+ ts="$(date +"%F %T.%6N")"
+ timedatectl set-ntp true
+ # test that we received a message from the mock time server
+ assert_timesyncd_ntp_message "$ts"
+
+ servers="$(busctl get-property org.freedesktop.timesync1 /org/freedesktop/timesync1 org.freedesktop.timesync1.Manager NTSKeyExchangeServers)"
+ secure="$(busctl get-property org.freedesktop.timesync1 /org/freedesktop/timesync1 org.freedesktop.timesync1.Manager SecureTime)"
+ chosen_server="$(busctl get-property org.freedesktop.timesync1 /org/freedesktop/timesync1 org.freedesktop.timesync1.Manager ServerName)"
+ [[ "$servers" == "as 3 \"does.not.exist\" \"$mock_server\" \"not.found\"" ]]
+ [[ "$chosen_server" == "s \"$mock_server\"" ]]
+ [[ "$secure" == "b true" ]]
+}
+
+assert_timesyncd_exhausted_servers() {
+ local timestamp="${1:?}"
+ local args=(-q --since="$timestamp" -u systemd-timesyncd --grep "Waiting after exhausting servers")
+
+ journalctl --sync
+
+ for _ in {0..9}; do
+ if journalctl "${args[@]}"; then
+ # Make the found entry in the archived journal, to avoid the following failure:
+ # Journal file /run/log/journal/.../system.journal is truncated, ignoring file.
+ journalctl --rotate
+ return 0
+ fi
+
+ sleep .5
+ done
+
+ return 1
+}
+
+testcase_nts_failure_modes() {
+ if systemd-detect-virt -cq; then
+ echo "This test case requires a VM, skipping..."
+ return 0
+ fi
+
+ if ! [ -x /usr/lib/systemd/tests/unit-tests/manual/test-nts-mockserver ]; then
+ echo "NTS not available, skipping..."
+ return 0
+ fi
+
+ local FAKEROOT_CA=/etc/ssl/certs/CA_dummy_cert.crt
+
+ save_netif_state
+
+ # shellcheck disable=SC2329
+ cleanup() {
+ rm -f "$FAKEROOT_CA"
+ rm -rf /run/systemd/timesyncd.conf.d
+ systemctl stop systemd-timesyncd
+ if systemctl is-active nts-mock.service; then
+ systemctl stop nts-mock.service
+ fi
+ restore_netif_state
+ }
+
+ trap cleanup RETURN ERR
+
+ # configure a few NTP servers so
+ local mock_server="localhost"
+ mkdir -p /run/systemd/timesyncd.conf.d
+ cat >/run/systemd/timesyncd.conf.d/timesyncd.conf <<EOF
+[Time]
+NTS=$mock_server
+KeyExchangeTimeoutSec=1
+ConnectionRetrySec=1
+PollIntervalMinSec=1
+PollIntervalMaxSec=1
+EOF
+ systemctl daemon-reload
+
+ # trick timesyncd (or rather, its call to "network_is_online()") into thinking
+ # that there is a network; otherwise timesyncd will never attempt to sync time
+ echo "partial" > /run/systemd/netif/state
+
+ install_mock_certificate "$mock_server" "$FAKEROOT_CA"
+
+ systemctl unmask systemd-timesyncd
+ systemctl restart systemd-timesyncd
+ timedatectl set-ntp false
+
+ # failure modes:
+ # - 1 NTP extension field error (causes NTP timeout)
+ # - 2 no NTP extension field (causes NTP timeout)
+ # - 3 malformed/malicious NTS response
+ # - 4 no reply on NTS handshake
+ # - 5 drop connection on NTS handshake
+ # - 6 timeout during NTS handshake
+ for failure_mode in $(seq 6); do
+ systemd-run --unit=nts-mock.service --working-directory=/tmp --collect \
+ /usr/lib/systemd/tests/unit-tests/manual/test-nts-mockserver "$failure_mode"
+
+ ts="$(date +"%F %T.%6N")"
+ timedatectl set-ntp true
+ # NTP timeout is not configurable
+ [ "$failure_mode" -gt 2 ] || sleep 10
+
+ assert_timesyncd_exhausted_servers "$ts"
+
+ timedatectl set-ntp false
+ if systemctl is-active nts-mock.service; then
+ systemctl stop nts-mock.service
+ fi
+ done
+}
+
run_testcases
touch /testok
@anthonyryan1
Copy link
Author

anthonyryan1 commented Mar 13, 2026

Personal testing notes:

systemctl edit systemd-timesyncd.service

populated with:

[Service]
Environment=SYSTEMD_LOG_LEVEL=debug

/etc/systemd/timesyncd.conf set to:

[Time]
NTS=time.cloudflare.com
NTS=time1.mbix.ca
NTS=time.web-clock.ca

restart:

systemctl restart systemd-timesyncd.service

verify server selection with:

timedatectl timesync-status

tail logs (excluding pre-restart):

journalctl _SYSTEMD_INVOCATION_ID=$(systemctl show -p InvocationID --value systemd-timesyncd.service) -n all -f > timesyncd-nts.log

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