[Git][NTPsec/ntpsec][2026-06-drop-libaes-siv] 3 commits: attic/aes-siv-timing.c and attic/aead-timing.c still included the

Mark Atwood (@MarkAtwood) gitlab at mg.gitlab.com
Sat Oct 3 03:48:32 UTC 2026



Mark Atwood pushed to branch 2026-06-drop-libaes-siv at NTPsec / ntpsec


Commits:
77dbfd89 by Mark Atwood at 2026-10-02T20:48:26-07:00
attic/aes-siv-timing.c and attic/aead-timing.c still included the
removed libaes_siv header, breaking the feature-attic CI job. Include
aes_siv_evp.h, which provides the same API, and drop the stale
../libaes_siv include path and aes_siv use from attic/wscript. These
tools are already built only when NTS is enabled.

- - - - -
e79c8cc3 by Mark Atwood at 2026-10-02T20:48:26-07:00
Fold the duplicated CMAC/dbl/xor blocks in siv_empty_tag() into
siv_s2v_step() so S2V component order lives in one place. Keep keying
CMAC on every step: on OpenSSL 3.0.0-3.0.2, EVP_MAC_init() with a NULL
key does not restart CMAC, so the cheaper restart gives wrong tags.

Document in aes_siv_evp.h that plaintext (encrypt) and out (decrypt)
may be NULL only for empty plaintext, and that *out_len is updated
only on success.

Update devel/hacking.adoc and devel/nts.adoc, which still described a
vendored libaes_siv, and state the OpenSSL 3.0 minimum.

- - - - -
cb0aef44 by Mark Atwood at 2026-10-02T20:48:26-07:00
The NTS tests only round-trip through aes_siv_evp.c, so a consistent
error in the hand-computed empty-plaintext tag would pass them. Add
known-answer tests for empty plaintext with 32/48/64-byte keys
(including tag-only decrypt with out=NULL and rejection of every
single-bit tag flip) and the RFC 5297 A.1 vector.

Expected values come from the removed libaes_siv and from
pycryptodome AES MODE_SIV, which agree byte for byte. The group runs
only when NTS is enabled.

Also drop the stale ../libaes_siv include and aes_siv use from
test_ntpd in tests/wscript.

- - - - -


10 changed files:

- attic/aead-timing.c
- attic/aes-siv-timing.c
- attic/wscript
- devel/hacking.adoc
- devel/nts.adoc
- include/aes_siv_evp.h
- libntp/aes_siv_evp.c
- tests/common/tests_main.c
- + tests/libntp/aes_siv_evp.c
- tests/wscript


Changes:

=====================================
attic/aead-timing.c
=====================================
@@ -37,7 +37,7 @@
 #include <openssl/ssl.h>
 #include <openssl/err.h>
 #include <openssl/rand.h>
-#include "aes_siv.h"
+#include "aes_siv_evp.h"
 
 #define UNUSED_ARG(arg)         ((void)(arg))
 #define INSIST(x)       assert(x)


=====================================
attic/aes-siv-timing.c
=====================================
@@ -26,7 +26,7 @@
 
 #include <openssl/err.h>
 #include <openssl/rand.h>
-#include "aes_siv.h"
+#include "aes_siv_evp.h"
 #include "ntp_fp.h"
 #include "nts.h"
 


=====================================
attic/wscript
=====================================
@@ -19,9 +19,9 @@ def build(ctx):
         ctx(
             target=name,
             features="c cprogram",
-            includes=[ctx.bldnode.parent.abspath(), "../include", "../libaes_siv"],
+            includes=[ctx.bldnode.parent.abspath(), "../include"],
             source=[name + ".c"],
-            use="ntp M CRYPTO SSL RT PTHREAD aes_siv",
+            use="ntp M CRYPTO SSL RT PTHREAD",
             install_path=None,
         )
 


=====================================
devel/hacking.adoc
=====================================
@@ -73,8 +73,9 @@ https://medium.com/@porteneuve/mastering-git-subtrees-943d29a798ec
 These don't even touch the biggest issue, which is stable reproducible
 builds.  For this reason, we use naive textual inclusion.
 
-So far this has only been an issue with libaes_siv. If you notice
-there's an inclusion not listed here, file an issue.
+This used to be an issue with libaes_siv; NTPsec now uses OpenSSL's
+native AES-SIV instead. If you notice there's an inclusion not listed
+here, file an issue.
 
 == C Guidelines
 
@@ -209,14 +210,19 @@ Random isn't cryptographically secure.  secure is.
 
 We depend on the host distro to provide OpenSSL.
 NTS-KE (key exchange) requires TLSv1.3 or newer.
-That means OpenSSL version 1.1.1 or newer.
+That means OpenSSL version 3.0 or newer.
 Shared key authentication uses their crypto package.
 We also use their RAND_bytes().
 
    Note that OpenSSL 1.0.1 is no longer supported.  2020-Feb-17
    Note that OpenSSL 1.1.0 is no longer supported.  2020-Mar-12
 
-We also use waf, libaes_siv, libjsmn, and unity but we have local
+NTS uses OpenSSL's native AES-SIV through the EVP interface, which
+needs OpenSSL 3.0 or newer; libntp/aes_siv_evp.c wraps it.  OpenSSL
+before 3.5.0 cannot tag an empty plaintext, so for that case the
+wrapper computes the RFC 5297 S2V tag itself using OpenSSL's CMAC.
+
+We also use waf, libjsmn, and unity but we have local
 copies rather than depending on the host distro to provide them.
 
 === Coding style and indentation


=====================================
devel/nts.adoc
=====================================
@@ -106,9 +106,10 @@ not a network connection.
 -    1 to 8 cookies link:{draft-nts}#section-4.1.6[4.1.6]
 -    The selected AEAD algorithm link:{draft-nts}#section-4.1.5[4.1.5]
 
-For AEAD, we need libaes_siv.so, RFC 5297
-It's not in OpenSSL yet.
-  https://github.com/dfoxfranke/libaes_siv
+For AEAD (AES-SIV, RFC 5297) we use OpenSSL's native EVP AES-SIV,
+which needs OpenSSL 3.0 or newer; see libntp/aes_siv_evp.c.
+OpenSSL before 3.5.0 cannot tag an empty plaintext, so for that case
+the wrapper computes the RFC 5297 S2V tag itself using OpenSSL's CMAC.
 
 An NTP client SHOULD NOT initiate NTS-KE by default.  Configuration
 parameters from the NTPD config file will tell the NTPD client when and


=====================================
include/aes_siv_evp.h
=====================================
@@ -76,6 +76,9 @@ int AES_SIV_CTX_copy(AES_SIV_CTX *dst, AES_SIV_CTX const *src);
  *
  * Returns: 1 on success, 0 on failure
  *
+ * plaintext may be NULL only when plaintext_len is 0.
+ * *out_len is updated only on success.
+ *
  * Key length determines the SIV variant:
  *   32 bytes -> AEAD_AES_SIV_CMAC_256 (AES-128)
  *   48 bytes -> AEAD_AES_SIV_CMAC_384 (AES-192)
@@ -109,6 +112,9 @@ int AES_SIV_Encrypt(AES_SIV_CTX *ctx, unsigned char *out, size_t *out_len,
  *
  * Returns: 1 on success (authentication passed), 0 on failure
  *
+ * out may be NULL only when the plaintext is empty (ciphertext_len is 16).
+ * *out_len is updated only on success.
+ *
  * Key length determines the SIV variant:
  *   32 bytes -> AEAD_AES_SIV_CMAC_256 (AES-128)
  *   48 bytes -> AEAD_AES_SIV_CMAC_384 (AES-192)


=====================================
libntp/aes_siv_evp.c
=====================================
@@ -114,6 +114,11 @@ static void siv_dbl(unsigned char block[SIV_TAG_LEN]) {
 	                                         (carry * 0x87));
 }
 
+/*
+ * CMAC(K1, in).  The key is passed on every call because on OpenSSL
+ * 3.0.0 to 3.0.2 EVP_MAC_init() with a NULL key does not restart CMAC,
+ * so a second MAC on the same context would continue the first.
+ */
 static int siv_cmac(EVP_MAC_CTX *mctx, const unsigned char *key, size_t key_len,
                     const OSSL_PARAM *params,
                     const unsigned char *in, size_t in_len,
@@ -125,10 +130,37 @@ static int siv_cmac(EVP_MAC_CTX *mctx, const unsigned char *key, size_t key_len,
 	       EVP_MAC_final(mctx, out, &out_len, SIV_TAG_LEN) == 1;
 }
 
+/*
+ * D = dbl(D) xor CMAC(K1, S_i) for one non-final component.
+ * Empty components are skipped, as AES_SIV_Encrypt() does when it
+ * feeds them to OpenSSL.
+ */
+static int siv_s2v_step(EVP_MAC_CTX *mctx, const unsigned char *key,
+                        size_t key_len, const OSSL_PARAM *params,
+                        unsigned char d[SIV_TAG_LEN],
+                        unsigned char const *in, size_t in_len) {
+	unsigned char t[SIV_TAG_LEN];
+	int i;
+
+	if (in == NULL || in_len == 0) {
+		return 1;
+	}
+	if (!siv_cmac(mctx, key, key_len, params, in, in_len, t)) {
+		return 0;
+	}
+	siv_dbl(d);
+	for (i = 0; i < SIV_TAG_LEN; i++) {
+		d[i] ^= t[i];
+	}
+	return 1;
+}
+
 /*
  * S2V(K1, ad, nonce, "") with K1 the first half of the SIV key.
- * Components are included under the same rules AES_SIV_Encrypt()
- * uses when it feeds them to OpenSSL.
+ *
+ * Note: this fetches CMAC and keys it (4 times) on every call.  The cost
+ * is unmeasured; if it shows up on a busy NTS server, cache the fetched
+ * EVP_MAC in AES_SIV_CTX next to cipher_ctx.
  */
 static int siv_empty_tag(unsigned char tag[SIV_TAG_LEN],
                          unsigned char const *key, size_t key_len,
@@ -142,10 +174,9 @@ static int siv_empty_tag(unsigned char tag[SIV_TAG_LEN],
 	EVP_MAC *mac;
 	EVP_MAC_CTX *mctx = NULL;
 	OSSL_PARAM params[2];
-	unsigned char d[SIV_TAG_LEN], t[SIV_TAG_LEN];
+	unsigned char d[SIV_TAG_LEN];
 	size_t k1_len = key_len / 2;
 	int ret = 0;
-	int i;
 
 	switch (key_len) {
 	case 32:
@@ -173,31 +204,13 @@ static int siv_empty_tag(unsigned char tag[SIV_TAG_LEN],
 	                                             cbc_name, 0);
 	params[1] = OSSL_PARAM_construct_end();
 
-	/* D = CMAC(K1, <zero>) */
-	if (!siv_cmac(mctx, key, k1_len, params, zero, sizeof(zero), d)) {
+	/* D = CMAC(K1, <zero>), then fold in AD and nonce, in that order */
+	if (!siv_cmac(mctx, key, k1_len, params, zero, sizeof(zero), d) ||
+	    !siv_s2v_step(mctx, key, k1_len, params, d, ad, ad_len) ||
+	    !siv_s2v_step(mctx, key, k1_len, params, d, nonce, nonce_len)) {
 		goto cleanup;
 	}
 
-	/* D = dbl(D) xor CMAC(K1, S_i) for each associated-data component */
-	if (ad != NULL && ad_len > 0) {
-		if (!siv_cmac(mctx, key, k1_len, params, ad, ad_len, t)) {
-			goto cleanup;
-		}
-		siv_dbl(d);
-		for (i = 0; i < SIV_TAG_LEN; i++) {
-			d[i] ^= t[i];
-		}
-	}
-	if (nonce != NULL && nonce_len > 0) {
-		if (!siv_cmac(mctx, key, k1_len, params, nonce, nonce_len, t)) {
-			goto cleanup;
-		}
-		siv_dbl(d);
-		for (i = 0; i < SIV_TAG_LEN; i++) {
-			d[i] ^= t[i];
-		}
-	}
-
 	/* Empty final component: T = dbl(D) xor pad(""), tag = CMAC(K1, T) */
 	siv_dbl(d);
 	d[0] ^= 0x80;


=====================================
tests/common/tests_main.c
=====================================
@@ -39,6 +39,9 @@ static void RunAllTests(void)
 #endif
 
 #ifdef TEST_LIBNTP
+#ifndef DISABLE_NTS
+	RUN_TEST_GROUP(aes_siv_evp);
+#endif
 	RUN_TEST_GROUP(authkeys);
 	RUN_TEST_GROUP(calendar);
 	RUN_TEST_GROUP(clocktime);


=====================================
tests/libntp/aes_siv_evp.c
=====================================
@@ -0,0 +1,156 @@
+/*
+ * Copyright the NTPsec project contributors
+ * SPDX-License-Identifier: BSD-2-Clause
+ *
+ * Known-answer tests for the AES-SIV wrapper in libntp/aes_siv_evp.c.
+ *
+ * The expected values were not produced by the code under test.  The
+ * empty-plaintext tags were computed with two independent
+ * implementations, which agree byte for byte:
+ *   - the libaes_siv library NTPsec vendored before it was replaced by
+ *     aes_siv_evp.c (libaes_siv/aes_siv.c at commit c7461cb7)
+ *   - pycryptodome AES.new(key, AES.MODE_SIV, nonce=nonce) with
+ *     update(ad), then encrypt_and_digest(b"")
+ * The non-empty vector is RFC 5297 Appendix A.1.
+ */
+
+#include "config.h"
+#include "aes_siv_evp.h"
+
+#include <string.h>
+
+#include "unity.h"
+#include "unity_fixture.h"
+
+#define TAG_LEN 16
+
+TEST_GROUP(aes_siv_evp);
+
+static AES_SIV_CTX *ctx;
+static unsigned char key[64];   /* key[i] = i; first key_len bytes used */
+static unsigned char ad[48];    /* ad[i] = 0x80 + i */
+static unsigned char nonce[16]; /* nonce[i] = 0xc0 + i */
+
+TEST_SETUP(aes_siv_evp) {
+	size_t i;
+
+	for (i = 0; i < sizeof(key); i++) {
+		key[i] = (unsigned char)i;
+	}
+	for (i = 0; i < sizeof(ad); i++) {
+		ad[i] = (unsigned char)(0x80 + i);
+	}
+	for (i = 0; i < sizeof(nonce); i++) {
+		nonce[i] = (unsigned char)(0xc0 + i);
+	}
+	ctx = AES_SIV_CTX_new();
+	TEST_ASSERT_NOT_NULL(ctx);
+}
+
+TEST_TEAR_DOWN(aes_siv_evp) {
+	AES_SIV_CTX_free(ctx);
+}
+
+/*
+ * Empty plaintext with AD and nonce, as NTS uses it: the output is the
+ * 16-byte S2V tag alone.  Decrypt must accept it without touching out
+ * (callers pass NULL) and must reject it with any single bit flipped.
+ */
+static void check_empty(size_t key_len, const unsigned char expected[TAG_LEN]) {
+	unsigned char tag[TAG_LEN];
+	size_t out_len = sizeof(tag);
+	size_t bit;
+
+	TEST_ASSERT_EQUAL_INT(1, AES_SIV_Encrypt(ctx, tag, &out_len,
+	                      key, key_len, nonce, sizeof(nonce),
+	                      NULL, 0, ad, sizeof(ad)));
+	TEST_ASSERT_EQUAL_UINT(TAG_LEN, out_len);
+	TEST_ASSERT_EQUAL_HEX8_ARRAY(expected, tag, TAG_LEN);
+
+	out_len = 1234;
+	TEST_ASSERT_EQUAL_INT(1, AES_SIV_Decrypt(ctx, NULL, &out_len,
+	                      key, key_len, nonce, sizeof(nonce),
+	                      expected, TAG_LEN, ad, sizeof(ad)));
+	TEST_ASSERT_EQUAL_UINT(0, out_len);
+
+	for (bit = 0; bit < 8 * TAG_LEN; bit++) {
+		memcpy(tag, expected, TAG_LEN);
+		tag[bit / 8] ^= (unsigned char)(1U << (bit % 8));
+		out_len = 0;
+		TEST_ASSERT_EQUAL_INT(0, AES_SIV_Decrypt(ctx, NULL, &out_len,
+		                      key, key_len, nonce, sizeof(nonce),
+		                      tag, TAG_LEN, ad, sizeof(ad)));
+	}
+}
+
+TEST(aes_siv_evp, EmptyPlaintext256) {
+	static const unsigned char expected[TAG_LEN] = {
+		0xb6, 0xc8, 0x28, 0x92, 0x52, 0x30, 0xfc, 0xe6,
+		0xdb, 0x8d, 0x74, 0xa4, 0x2c, 0x96, 0x01, 0xdc
+	};
+	check_empty(32, expected);
+}
+
+TEST(aes_siv_evp, EmptyPlaintext384) {
+	static const unsigned char expected[TAG_LEN] = {
+		0xea, 0x2a, 0x49, 0x06, 0x43, 0x80, 0x99, 0xe1,
+		0x98, 0xd4, 0xbc, 0x9e, 0xcc, 0x83, 0x4b, 0x8a
+	};
+	check_empty(48, expected);
+}
+
+TEST(aes_siv_evp, EmptyPlaintext512) {
+	static const unsigned char expected[TAG_LEN] = {
+		0x2e, 0x41, 0xac, 0x72, 0x83, 0xef, 0x68, 0x48,
+		0xf7, 0xb3, 0xac, 0x67, 0xbe, 0xc1, 0x67, 0x4f
+	};
+	check_empty(64, expected);
+}
+
+/* RFC 5297 Appendix A.1: deterministic AEAD, AD but no nonce. */
+TEST(aes_siv_evp, RFC5297_A1) {
+	static const unsigned char k[32] = {
+		0xff, 0xfe, 0xfd, 0xfc, 0xfb, 0xfa, 0xf9, 0xf8,
+		0xf7, 0xf6, 0xf5, 0xf4, 0xf3, 0xf2, 0xf1, 0xf0,
+		0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7,
+		0xf8, 0xf9, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0xff
+	};
+	static const unsigned char a[24] = {
+		0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
+		0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f,
+		0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27
+	};
+	static const unsigned char p[14] = {
+		0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
+		0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee
+	};
+	static const unsigned char expected[30] = {
+		0x85, 0x63, 0x2d, 0x07, 0xc6, 0xe8, 0xf3, 0x7f,
+		0x95, 0x0a, 0xcd, 0x32, 0x0a, 0x2e, 0xcc, 0x93,
+		0x40, 0xc0, 0x2b, 0x96, 0x90, 0xc4, 0xdc, 0x04,
+		0xda, 0xef, 0x7f, 0x6a, 0xfe, 0x5c
+	};
+	unsigned char out[sizeof(expected)];
+	unsigned char pt[sizeof(p)];
+	size_t out_len = sizeof(out);
+
+	TEST_ASSERT_EQUAL_INT(1, AES_SIV_Encrypt(ctx, out, &out_len,
+	                      k, sizeof(k), NULL, 0,
+	                      p, sizeof(p), a, sizeof(a)));
+	TEST_ASSERT_EQUAL_UINT(sizeof(expected), out_len);
+	TEST_ASSERT_EQUAL_HEX8_ARRAY(expected, out, sizeof(expected));
+
+	out_len = sizeof(pt);
+	TEST_ASSERT_EQUAL_INT(1, AES_SIV_Decrypt(ctx, pt, &out_len,
+	                      k, sizeof(k), NULL, 0,
+	                      expected, sizeof(expected), a, sizeof(a)));
+	TEST_ASSERT_EQUAL_UINT(sizeof(p), out_len);
+	TEST_ASSERT_EQUAL_HEX8_ARRAY(p, pt, sizeof(p));
+}
+
+TEST_GROUP_RUNNER(aes_siv_evp) {
+	RUN_TEST_CASE(aes_siv_evp, EmptyPlaintext256);
+	RUN_TEST_CASE(aes_siv_evp, EmptyPlaintext384);
+	RUN_TEST_CASE(aes_siv_evp, EmptyPlaintext512);
+	RUN_TEST_CASE(aes_siv_evp, RFC5297_A1);
+}


=====================================
tests/wscript
=====================================
@@ -58,6 +58,9 @@ def build(ctx):
         "libntp/ymd2yd.c"
     ] + common_source
 
+    if not ctx.env.DISABLE_NTS:
+        libntp_source += ["libntp/aes_siv_evp.c"]
+
     ctx.ntp_test(
         features="c cprogram test",
         target="test_libntp",
@@ -109,11 +112,11 @@ def build(ctx):
     ctx.ntp_test(
         defines=unity_config + ["TEST_NTPD=1"],
         features="c cprogram test",
-        includes=[ctx.bldnode.parent.abspath(), "../include", "unity", "../ntpd", "common", "../libaes_siv"],
+        includes=[ctx.bldnode.parent.abspath(), "../include", "unity", "../ntpd", "common"],
         install_path=None,
         source=ntpd_source,
         target="test_ntpd",
-        use="ntpd_lib libntpd_obj unity ntp aes_siv "
+        use="ntpd_lib libntpd_obj unity ntp "
             "M PTHREAD CRYPTO RT SOCKET NSL",
     )
 



View it on GitLab: https://gitlab.com/NTPsec/ntpsec/-/compare/7735674eade880b881a19e992cea5255d354136e...cb0aef44fa30fb086daaf54a1ae5643ba4f5e0dc

-- 
View it on GitLab: https://gitlab.com/NTPsec/ntpsec/-/compare/7735674eade880b881a19e992cea5255d354136e...cb0aef44fa30fb086daaf54a1ae5643ba4f5e0dc
You're receiving this email because of your account on gitlab.com. Manage all notifications: https://gitlab.com/-/profile/notifications | Help: https://gitlab.com/help


-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://lists.ntpsec.org/pipermail/vc/attachments/20261003/57a4e607/attachment-0001.htm>


More information about the vc mailing list