[Git][NTPsec/ntpsec][2026-06-drop-libaes-siv] fix: handle empty plaintext in AES-SIV wrapper

Mark Atwood (@MarkAtwood) gitlab at mg.gitlab.com
Sat Oct 3 01:42:53 UTC 2026



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


Commits:
7735674e by Mark Atwood at 2026-10-02T18:42:48-07:00
fix: handle empty plaintext in AES-SIV wrapper

OpenSSL before 3.5.0 cannot produce an AES-SIV tag for an empty
plaintext: the SIV provider returns early on zero-length input and
the final step fails. NTS authenticates client packets with an empty
plaintext, so extens_client_send() hit exit(1). Compute that tag
directly with RFC 5297 S2V over AES-CMAC, and verify tag-only input
with CRYPTO_memcmp(). Remove once supported distributions ship
OpenSSL >= 3.5.0.

Copy the received tag into a local buffer instead of casting away
const for EVP_CTRL_AEAD_SET_TAG, which broke -Werror=cast-qual builds.

Build aes_siv_evp.c only when NTS is enabled. The OpenSSL >= 3.0
check is NTS-only, so --disable-nts builds against older OpenSSL
failed to compile it.

Verified bit-exact against the removed libaes_siv for 32/48/64-byte
keys and plaintext lengths 0-64 on OpenSSL 3.0.13, 3.3.7 and 3.5.9.

- - - - -


2 changed files:

- libntp/aes_siv_evp.c
- libntp/wscript


Changes:

=====================================
libntp/aes_siv_evp.c
=====================================
@@ -20,6 +20,7 @@
 
 #include <string.h>
 #include <openssl/evp.h>
+#include <openssl/core_names.h>
 #include <openssl/crypto.h>
 #include <openssl/err.h>
 
@@ -89,6 +90,125 @@ static const char *get_cipher_name(size_t key_len) {
 	}
 }
 
+/*
+ * OpenSSL before 3.5.0 cannot produce an AES-SIV tag for an empty
+ * plaintext: its SIV provider returns early on zero-length input, so
+ * the final step fails.  NTS authenticates packets with an empty
+ * plaintext, so for that case we compute the tag ourselves with the
+ * RFC 5297 S2V construction over AES-CMAC.  The ciphertext is empty,
+ * so the tag is the whole output.
+ *
+ * Remove this once the distributions we support ship OpenSSL >= 3.5.0
+ * (Debian oldstable and Ubuntu 24.04 LTS were still on 3.0.x in 2026).
+ */
+
+/* RFC 5297 doubling in GF(2^128) */
+static void siv_dbl(unsigned char block[SIV_TAG_LEN]) {
+	unsigned char carry = block[0] >> 7;
+	int i;
+
+	for (i = 0; i < SIV_TAG_LEN - 1; i++) {
+		block[i] = (unsigned char)((block[i] << 1) | (block[i + 1] >> 7));
+	}
+	block[SIV_TAG_LEN - 1] = (unsigned char)((block[SIV_TAG_LEN - 1] << 1) ^
+	                                         (carry * 0x87));
+}
+
+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,
+                    unsigned char out[SIV_TAG_LEN]) {
+	size_t out_len;
+
+	return EVP_MAC_init(mctx, key, key_len, params) == 1 &&
+	       EVP_MAC_update(mctx, in, in_len) == 1 &&
+	       EVP_MAC_final(mctx, out, &out_len, SIV_TAG_LEN) == 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.
+ */
+static int siv_empty_tag(unsigned char tag[SIV_TAG_LEN],
+                         unsigned char const *key, size_t key_len,
+                         unsigned char const *nonce, size_t nonce_len,
+                         unsigned char const *ad, size_t ad_len) {
+	static const unsigned char zero[SIV_TAG_LEN];
+	static char cbc128[] = "AES-128-CBC";
+	static char cbc192[] = "AES-192-CBC";
+	static char cbc256[] = "AES-256-CBC";
+	char *cbc_name;
+	EVP_MAC *mac;
+	EVP_MAC_CTX *mctx = NULL;
+	OSSL_PARAM params[2];
+	unsigned char d[SIV_TAG_LEN], t[SIV_TAG_LEN];
+	size_t k1_len = key_len / 2;
+	int ret = 0;
+	int i;
+
+	switch (key_len) {
+	case 32:
+		cbc_name = cbc128;
+		break;
+	case 48:
+		cbc_name = cbc192;
+		break;
+	case 64:
+		cbc_name = cbc256;
+		break;
+	default:
+		return 0;
+	}
+
+	mac = EVP_MAC_fetch(NULL, "CMAC", NULL);
+	if (mac == NULL) {
+		return 0;
+	}
+	mctx = EVP_MAC_CTX_new(mac);
+	if (mctx == NULL) {
+		goto cleanup;
+	}
+	params[0] = OSSL_PARAM_construct_utf8_string(OSSL_MAC_PARAM_CIPHER,
+	                                             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)) {
+		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;
+	ret = siv_cmac(mctx, key, k1_len, params, d, SIV_TAG_LEN, tag);
+
+cleanup:
+	EVP_MAC_CTX_free(mctx);
+	EVP_MAC_free(mac);
+	return ret;
+}
+
 int AES_SIV_Encrypt(AES_SIV_CTX *ctx, unsigned char *out, size_t *out_len,
                     unsigned char const *key, size_t key_len,
                     unsigned char const *nonce, size_t nonce_len,
@@ -112,6 +232,15 @@ int AES_SIV_Encrypt(AES_SIV_CTX *ctx, unsigned char *out, size_t *out_len,
 		return 0;
 	}
 
+	if (plaintext_len == 0) {
+		if (!siv_empty_tag(out, key, key_len, nonce, nonce_len, ad, ad_len)) {
+			OPENSSL_cleanse(out, SIV_TAG_LEN);
+			return 0;
+		}
+		*out_len = SIV_TAG_LEN;
+		return 1;
+	}
+
 	cipher_name = get_cipher_name(key_len);
 	if (cipher_name == NULL) {
 		return 0;
@@ -163,10 +292,8 @@ int AES_SIV_Encrypt(AES_SIV_CTX *ctx, unsigned char *out, size_t *out_len,
 	tag_out = out;
 	ct_out = out + SIV_TAG_LEN;
 
-	/* Encrypt the plaintext (use empty buffer if NULL to satisfy OpenSSL) */
-	unsigned char empty = 0;
-	const unsigned char *pt = (plaintext != NULL) ? plaintext : ∅
-	if (EVP_EncryptUpdate(ctx->cipher_ctx, ct_out, &len, pt, (int)plaintext_len) != 1) {
+	/* Encrypt the plaintext */
+	if (EVP_EncryptUpdate(ctx->cipher_ctx, ct_out, &len, plaintext, (int)plaintext_len) != 1) {
 		goto cleanup;
 	}
 
@@ -202,7 +329,7 @@ int AES_SIV_Decrypt(AES_SIV_CTX *ctx, unsigned char *out, size_t *out_len,
 	int len;
 	int ret = 0;
 	size_t plaintext_len;
-	const unsigned char *tag_in;
+	unsigned char tag_in[SIV_TAG_LEN];
 	const unsigned char *ct_in;
 
 	if (ctx == NULL || ctx->cipher_ctx == NULL) {
@@ -221,6 +348,17 @@ int AES_SIV_Decrypt(AES_SIV_CTX *ctx, unsigned char *out, size_t *out_len,
 		return 0;
 	}
 
+	if (plaintext_len == 0) {
+		unsigned char expected[SIV_TAG_LEN];
+
+		if (!siv_empty_tag(expected, key, key_len, nonce, nonce_len, ad, ad_len) ||
+		    CRYPTO_memcmp(expected, ciphertext, SIV_TAG_LEN) != 0) {
+			return 0;
+		}
+		*out_len = 0;
+		return 1;
+	}
+
 	cipher_name = get_cipher_name(key_len);
 	if (cipher_name == NULL) {
 		return 0;
@@ -243,12 +381,13 @@ int AES_SIV_Decrypt(AES_SIV_CTX *ctx, unsigned char *out, size_t *out_len,
 	/*
 	 * SIV input format: tag (16 bytes) || ciphertext
 	 */
-	tag_in = ciphertext;
+	/* Copy the tag: EVP_CIPHER_CTX_ctrl() takes a non-const pointer. */
+	memcpy(tag_in, ciphertext, SIV_TAG_LEN);
 	ct_in = ciphertext + SIV_TAG_LEN;
 
 	/* Set the expected tag for verification */
 	if (EVP_CIPHER_CTX_ctrl(ctx->cipher_ctx, EVP_CTRL_AEAD_SET_TAG,
-	                        SIV_TAG_LEN, (void *)tag_in) != 1) {
+	                        SIV_TAG_LEN, tag_in) != 1) {
 		goto cleanup;
 	}
 
@@ -266,20 +405,15 @@ int AES_SIV_Decrypt(AES_SIV_CTX *ctx, unsigned char *out, size_t *out_len,
 		}
 	}
 
-	/* Decrypt the ciphertext (use empty buffers if NULL to satisfy OpenSSL) */
-	unsigned char empty = 0;
-	unsigned char *out_ptr = (out != NULL) ? out : ∅
-	const unsigned char *ct_ptr = (plaintext_len > 0) ? ct_in : ∅
-	if (EVP_DecryptUpdate(ctx->cipher_ctx, out_ptr, &len, ct_ptr, (int)plaintext_len) != 1) {
+	/* Decrypt the ciphertext */
+	if (EVP_DecryptUpdate(ctx->cipher_ctx, out, &len, ct_in, (int)plaintext_len) != 1) {
 		goto cleanup;
 	}
 
 	/* Finalize and verify authentication tag */
-	if (EVP_DecryptFinal_ex(ctx->cipher_ctx, out_ptr + len, &len) != 1) {
+	if (EVP_DecryptFinal_ex(ctx->cipher_ctx, out + len, &len) != 1) {
 		/* Authentication failed - clear output */
-		if (out != NULL && plaintext_len > 0) {
-			OPENSSL_cleanse(out, plaintext_len);
-		}
+		OPENSSL_cleanse(out, plaintext_len);
 		goto cleanup;
 	}
 


=====================================
libntp/wscript
=====================================
@@ -11,7 +11,6 @@ from waflib.Logs import pprint
 def build(ctx):
 
     libntp_source = [
-        "aes_siv_evp.c",
         "authkeys.c",
         "authreadkeys.c",
         "clocktime.c",
@@ -31,6 +30,11 @@ def build(ctx):
         "syssignal.c",
     ]
 
+    if not ctx.env.DISABLE_NTS:
+      libntp_source += [
+        "aes_siv_evp.c",
+    ]
+
     libntp_source_sharable = [
         "assert.c",
         "clockwork.c",



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

-- 
View it on GitLab: https://gitlab.com/NTPsec/ntpsec/-/commit/7735674eade880b881a19e992cea5255d354136e
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/ebec0a38/attachment-0001.htm>


More information about the vc mailing list