Commit 20378a1980 for bind

commit 20378a19800954d609873bc5a076ec6540cfec5f
Author: OndÅ™ej Surý <ondrej@isc.org>
Date:   Wed Dec 31 12:06:11 2025 +0100

    Add ML-DSA-44 support for DNSSEC

    Implement draft-westerbaan-dnssec-mldsa-04 with OpenSSL so MLDSA44 keys
    and signatures interoperate using the assigned algorithm number and
    pure ML-DSA with an empty context. Exercise the published example and
    signing, validation, and malformed inputs in unit and system tests.

diff --git a/REUSE.toml b/REUSE.toml
index d5a03a653b..7aa1ff9a25 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -210,3 +210,14 @@ path = [
 precedence = "aggregate"
 SPDX-FileCopyrightText = "Internet Systems Consortium, Inc. (\"ISC\")"
 SPDX-License-Identifier = "CC0-1.0"
+
+[[annotations]]
+path = [
+	"tests/dns/testdata/dst/Kexample.com.+018+59829.key",
+	"tests/dns/testdata/dst/Kexample.com.+018+59829.private",
+	"tests/dns/testdata/dst/mldsa.data",
+	"tests/dns/testdata/dst/mldsa.sig"
+]
+precedence = "override"
+SPDX-FileCopyrightText = "2026 IETF Trust and the persons identified as the document authors"
+SPDX-License-Identifier = "BSD-3-Clause"
diff --git a/bin/dnssec/dnssec-keygen.c b/bin/dnssec/dnssec-keygen.c
index e2cb16d4e7..d029e66d91 100644
--- a/bin/dnssec/dnssec-keygen.c
+++ b/bin/dnssec/dnssec-keygen.c
@@ -146,7 +146,8 @@ usage(int ret) {
 	}
 	fprintf(stderr, "        RSASHA256 | RSASHA512 |\n");
 	fprintf(stderr, "        ECDSAP256SHA256 | ECDSAP384SHA384 |\n");
-	fprintf(stderr, "        ED25519 | ED448\n");
+	fprintf(stderr, "        ED25519 | ED448 |\n");
+	fprintf(stderr, "        MLDSA44\n");
 	fprintf(stderr, "    -3: use NSEC3-capable algorithm\n");
 	fprintf(stderr, "    -b <key size in bits>:\n");
 	if (!isc_crypto_fips_mode()) {
@@ -162,6 +163,7 @@ usage(int ret) {
 	fprintf(stderr, "        ECDSAP384SHA384:\tignored\n");
 	fprintf(stderr, "        ED25519:\tignored\n");
 	fprintf(stderr, "        ED448:\tignored\n");
+	fprintf(stderr, "        MLDSA44:\tignored\n");
 	fprintf(stderr, "        (key size defaults are set according to\n"
 			"        algorithm and usage (ZSK or KSK)\n");
 	fprintf(stderr, "    -c <class>: (default: IN)\n");
@@ -288,6 +290,7 @@ keygen(keygen_ctx_t *ctx, const char *keyname) {
 			case DST_ALG_ECDSA384:
 			case DST_ALG_ED25519:
 			case DST_ALG_ED448:
+			case DST_ALG_MLDSA44:
 				break;
 			default:
 				fatal("algorithm %s is incompatible with NSEC3"
@@ -322,6 +325,7 @@ keygen(keygen_ctx_t *ctx, const char *keyname) {
 			case DST_ALG_ECDSA384:
 			case DST_ALG_ED25519:
 			case DST_ALG_ED448:
+			case DST_ALG_MLDSA44:
 				break;
 			default:
 				fatal("key size not specified (-b option)");
@@ -492,6 +496,9 @@ keygen(keygen_ctx_t *ctx, const char *keyname) {
 	case DST_ALG_ED448:
 		ctx->size = 456;
 		break;
+	case DST_ALG_MLDSA44:
+		ctx->size = DNS_KEY_MLDSA44SIZE * 8;
+		break;
 	default:
 		fatal("not a dnskey algorithm %u\n", ctx->alg);
 	}
@@ -512,6 +519,7 @@ keygen(keygen_ctx_t *ctx, const char *keyname) {
 	case DST_ALG_ECDSA384:
 	case DST_ALG_ED25519:
 	case DST_ALG_ED448:
+	case DST_ALG_MLDSA44:
 		show_progress = true;
 		break;
 	default:
diff --git a/bin/dnssec/dnssec-keygen.rst b/bin/dnssec/dnssec-keygen.rst
index 19b3e93f1f..e6486dc4e5 100644
--- a/bin/dnssec/dnssec-keygen.rst
+++ b/bin/dnssec/dnssec-keygen.rst
@@ -49,7 +49,7 @@ Options
    This option selects the cryptographic algorithm. For DNSSEC keys,
    the value of ``algorithm`` must be one of RSASHA1 (deprecated),
    NSEC3RSASHA1 (deprecated), RSASHA256, RSASHA512, ECDSAP256SHA256,
-   ECDSAP384SHA384, ED25519, or ED448.
+   ECDSAP384SHA384, ED25519, ED448, or MLDSA44.

    These values are case-insensitive. In some cases, abbreviations
    are supported, such as ECDSA256 for ECDSAP256SHA256 and ECDSA384
diff --git a/bin/dnssec/dnssec-ksr.c b/bin/dnssec/dnssec-ksr.c
index 6e3f15a2b3..66c9c43a35 100644
--- a/bin/dnssec/dnssec-ksr.c
+++ b/bin/dnssec/dnssec-ksr.c
@@ -353,6 +353,9 @@ create_key(ksr_ctx_t *ksr, dns_kasp_t *kasp, dns_kasp_key_t *kaspkey,
 	case DST_ALG_ED448:
 		ksr->size = 456;
 		break;
+	case DST_ALG_MLDSA44:
+		ksr->size = DNS_KEY_MLDSA44SIZE * 8;
+		break;
 	default:
 		show_progress = false;
 		break;
@@ -512,7 +515,7 @@ print_rdata(dns_rdataset_t *rrset) {
 	isc_buffer_t target;
 	isc_region_t r;
 	isc_result_t result;
-	char buf[4096];
+	char buf[65536];

 	isc_buffer_init(&target, buf, sizeof(buf));
 	result = dns_rdataset_totext(rrset, name, false, false, &target);
diff --git a/bin/tests/system/isctest/algorithms.py b/bin/tests/system/isctest/algorithms.py
index d833e39734..104b91b224 100644
--- a/bin/tests/system/isctest/algorithms.py
+++ b/bin/tests/system/isctest/algorithms.py
@@ -38,6 +38,7 @@ ECDSAP256SHA256 = Algorithm("ECDSAP256SHA256", 13, 13, 256)
 ECDSAP384SHA384 = Algorithm("ECDSAP384SHA384", 14, 14, 384)
 ED25519 = Algorithm("ED25519", 15, 15, 256)
 ED448 = Algorithm("ED448", 16, 16, 456)
+MLDSA44 = Algorithm("MLDSA44", 18, 18, 1312 * 8)
 RSASHA256OID = Algorithm("RSASHA256OID", 254, 256, 2048)
 RSASHA512OID = Algorithm("RSASHA512OID", 254, 257, 2048)

@@ -50,6 +51,7 @@ ALL_ALGORITHMS = [
     ECDSAP384SHA384,
     ED25519,
     ED448,
+    MLDSA44,
     RSASHA256OID,
     RSASHA512OID,
 ]
diff --git a/bin/tests/system/isctest/vars/algorithms.py b/bin/tests/system/isctest/vars/algorithms.py
index 6b6e9b3c7d..e061c27e2d 100644
--- a/bin/tests/system/isctest/vars/algorithms.py
+++ b/bin/tests/system/isctest/vars/algorithms.py
@@ -141,6 +141,7 @@ CRYPTO_SUPPORTED_VARS = {
     "ECDSAP384SHA384_SUPPORTED": "0",
     "ED25519_SUPPORTED": "0",
     "ED448_SUPPORTED": "0",
+    "MLDSA44_SUPPORTED": "0",
     "RSASHA256OID_SUPPORTED": "0",
     "RSASHA512OID_SUPPORTED": "0",
 }
diff --git a/bin/tests/system/mldsa/ns1/named.conf.j2 b/bin/tests/system/mldsa/ns1/named.conf.j2
new file mode 100644
index 0000000000..2febbc8596
--- /dev/null
+++ b/bin/tests/system/mldsa/ns1/named.conf.j2
@@ -0,0 +1,8 @@
+options {
+	{% include_indented "_common/options.conf.j2" %}
+	recursion no;
+	dnssec-validation no;
+};
+
+{% include "_common/controls.conf.j2" %}
+{% include "_common/zones.conf.j2" %}
diff --git a/bin/tests/system/mldsa/ns2/named.conf.j2 b/bin/tests/system/mldsa/ns2/named.conf.j2
new file mode 100644
index 0000000000..4d958e5bc5
--- /dev/null
+++ b/bin/tests/system/mldsa/ns2/named.conf.j2
@@ -0,0 +1,9 @@
+options {
+	{% include_indented "_common/options.conf.j2" %}
+	recursion yes;
+	dnssec-validation yes;
+};
+
+{% include "_common/controls.conf.j2" %}
+{% include "_common/trusted.conf.j2" %}
+{% include "_common/root.hint.conf" %}
diff --git a/bin/tests/system/mldsa/tests_mldsa.py b/bin/tests/system/mldsa/tests_mldsa.py
new file mode 100644
index 0000000000..91bf34784b
--- /dev/null
+++ b/bin/tests/system/mldsa/tests_mldsa.py
@@ -0,0 +1,164 @@
+# Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+#
+# SPDX-License-Identifier: MPL-2.0
+#
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, you can obtain one at https://mozilla.org/MPL/2.0/.
+#
+# See the COPYRIGHT file distributed with this work for additional
+# information regarding copyright ownership.
+
+from pathlib import Path
+
+import base64
+import os
+import shutil
+
+import dns.flags
+import dns.rdata
+import dns.rdatatype
+import pytest
+
+from isctest.algorithms import MLDSA44
+from isctest.template import NS1, zones
+from isctest.zone import FileZoneKey, Zone
+
+import isctest
+import isctest.mark
+
+pytestmark = [
+    isctest.mark.with_algorithm("MLDSA44"),
+    pytest.mark.extra_artifacts(
+        [
+            "ns*/dsset-*",
+            "ns*/zones/*.db",
+            "ns*/zones/*.db.signed",
+        ]
+    ),
+]
+
+
+def bootstrap():
+    # configure_root() signs with the default algorithm, so drive the steps
+    # here to sign the root zone with ML-DSA-44 keys
+    root = Zone(".", NS1, signed=True)
+    root.keys = [
+        FileZoneKey.generate(root, "-f KSK", alg=MLDSA44),
+        FileZoneKey.generate(root, alg=MLDSA44),
+    ]
+    root.render(template="_common/zones/root.db.j2.manual")
+    root.sign()
+
+    return {
+        "trust_anchors": root.trust_anchors(),
+        "zones": zones([root]),
+    }
+
+
+def test_mldsa_keygen_seed():
+    """
+    dnssec-keygen stores the 32-byte seed as the ML-DSA-44 private key.
+    """
+    privates = list(Path("ns1/keys").glob("K*.private"))
+    assert len(privates) == 2
+    for private in privates:
+        seed = next(
+            line.split()[1]
+            for line in private.read_text(encoding="ascii").splitlines()
+            if line.startswith("PrivateKey:")
+        )
+        assert len(base64.b64decode(seed)) == 32
+
+
+def test_mldsa_verify():
+    isctest.run.EnvCmd("VERIFY")("-o . zones/root.db.signed", cwd="ns1")
+
+
+def test_mldsa_authoritative_and_resolver(ns1, ns2):
+    msg = isctest.query.create(".", "SOA")
+    authoritative = isctest.query.tcp(msg, ns1.ip)
+    validated = isctest.query.tcp(msg, ns2.ip)
+    isctest.check.noerror(authoritative)
+    isctest.check.same_answer(authoritative, validated)
+    isctest.check.adflag(validated)
+    rrsigs = [
+        rrset for rrset in validated.answer if rrset.rdtype == dns.rdatatype.RRSIG
+    ]
+    assert rrsigs
+    for rrset in rrsigs:
+        for signature in rrset:
+            assert signature.algorithm == MLDSA44.number
+            assert len(signature.signature) == 2420
+
+
+def test_mldsa_udp_truncation(ns1, ns2):
+    msg = isctest.query.create(".", "DNSKEY")
+    response = isctest.query.udp(msg, ns1.ip)
+    assert response.flags & dns.flags.TC
+    response = isctest.query.tcp(msg, ns2.ip)
+    isctest.check.noerror(response)
+    isctest.check.adflag(response)
+    keys = response.find_rrset(response.answer, ".", "IN", "DNSKEY")
+    assert len(keys) == 2
+    assert all(key.algorithm == MLDSA44.number and len(key.key) == 1312 for key in keys)
+
+
+def draft_key():
+    return (
+        Path(os.environ["TOP_SRCDIR"])
+        / "tests/dns/testdata/dst"
+        / "Kexample.com.+018+59829"
+    )
+
+
+def test_mldsa_draft_ds():
+    ds = isctest.run.cmd(
+        [os.environ["DSFROMKEY"], "-a", "sha-256", f"{draft_key()}.key"]
+    ).out.split()
+    assert ds[:6] == ["example.com.", "IN", "DS", "59829", "18", "2"]
+    assert "".join(ds[6:]).lower() == (
+        "812cb1a22af04380e2f72d91c06c14eb1a918cf30037a8a9c67497e9264b4bfa"
+    )
+
+
+@pytest.mark.parametrize(
+    "seed",
+    [None, bytes(31), bytes(33), bytes(32)],
+    ids=["missing", "short", "long", "mismatch"],
+)
+def test_mldsa_bad_private_seed(tmp_path, seed):
+    """
+    Reject a missing seed, wrong seed lengths, and a seed that doesn't
+    match the DNSKEY.
+    """
+    key = draft_key()
+    shutil.copyfile(f"{key}.key", tmp_path / f"{key.name}.key")
+    private = "Private-key-format: v1.3\nAlgorithm: 18 (MLDSA44)\n"
+    if seed is not None:
+        private += f"PrivateKey: {base64.b64encode(seed).decode('ascii')}\n"
+    (tmp_path / f"{key.name}.private").write_text(private, encoding="ascii")
+    result = isctest.run.cmd(
+        [os.environ["SETTIME"], "-p", "all", key.name],
+        cwd=tmp_path,
+        raise_on_exception=False,
+    )
+    assert result.rc != 0
+    assert "private key is invalid" in result.err
+
+
+def test_mldsa_policy_keygen(tmp_path):
+    config = tmp_path / "policy.conf"
+    config.write_text(
+        'dnssec-policy "mldsa" { keys { csk lifetime unlimited algorithm MLDSA44; }; };\n',
+        encoding="ascii",
+    )
+    key = isctest.run.cmd(
+        [os.environ["KEYGEN"], "-k", "mldsa", "-l", str(config), "policy.example."],
+        cwd=tmp_path,
+    ).out.strip()
+    text = (tmp_path / f"{key}.key").read_text(encoding="ascii")
+    record = next(line for line in text.splitlines() if not line.startswith(";"))
+    key = dns.rdata.from_text("IN", "DNSKEY", record.split("DNSKEY", 1)[1])
+    assert key.algorithm == MLDSA44.number
+    assert len(key.key) == 1312
diff --git a/lib/dns/dst_api.c b/lib/dns/dst_api.c
index c1cb110240..669b0ed9aa 100644
--- a/lib/dns/dst_api.c
+++ b/lib/dns/dst_api.c
@@ -217,6 +217,9 @@ dst__lib_initialize(void) {
 #ifdef HAVE_OPENSSL_ED448
 	dst__openssleddsa_init(&dst_t_func[DST_ALG_ED448], DST_ALG_ED448);
 #endif /* ifdef HAVE_OPENSSL_ED448 */
+#ifdef HAVE_OPENSSL_MLDSA44
+	dst__opensslmldsa_init(&dst_t_func[DST_ALG_MLDSA44]);
+#endif /* ifdef HAVE_OPENSSL_MLDSA44 */

 #if HAVE_GSSAPI
 	dst__gssapi_init(&dst_t_func[DST_ALG_GSSAPI]);
@@ -1291,6 +1294,9 @@ dst_key_sigsize(const dst_key_t *key, unsigned int *n) {
 	case DST_ALG_ED448:
 		*n = DNS_SIG_ED448SIZE;
 		break;
+	case DST_ALG_MLDSA44:
+		*n = DNS_SIG_MLDSA44SIZE;
+		break;
 	case DST_ALG_HMACMD5:
 		*n = ISC_MD5_DIGESTLENGTH;
 		break;
@@ -1754,6 +1760,7 @@ issymmetric(const dst_key_t *key) {
 	case DST_ALG_ECDSA384:
 	case DST_ALG_ED25519:
 	case DST_ALG_ED448:
+	case DST_ALG_MLDSA44:
 		return false;
 	case DST_ALG_HMACMD5:
 	case DST_ALG_HMACSHA1:
diff --git a/lib/dns/dst_internal.h b/lib/dns/dst_internal.h
index 550951e984..fa1fd1009f 100644
--- a/lib/dns/dst_internal.h
+++ b/lib/dns/dst_internal.h
@@ -95,6 +95,7 @@ struct dst_key {
 		struct {
 			EVP_PKEY *pub;
 			EVP_PKEY *priv;
+			uint8_t *seed; /*%< ML-DSA private key */
 		} pkeypair;
 	} keydata; /*%< pointer to key in crypto pkg fmt */

@@ -197,6 +198,10 @@ void
 dst__opensslecdsa_init(struct dst_func **funcp);
 void
 dst__openssleddsa_init(struct dst_func **funcp, unsigned char algorithm);
+#ifdef HAVE_OPENSSL_MLDSA44
+void
+dst__opensslmldsa_init(struct dst_func **funcp);
+#endif /* HAVE_OPENSSL_MLDSA44 */
 #if HAVE_GSSAPI
 void
 dst__gssapi_init(struct dst_func **funcp);
diff --git a/lib/dns/dst_parse.c b/lib/dns/dst_parse.c
index bef7de650c..4da1020bb2 100644
--- a/lib/dns/dst_parse.c
+++ b/lib/dns/dst_parse.c
@@ -42,6 +42,7 @@
 #include <isc/string.h>
 #include <isc/util.h>

+#include <dns/keyvalues.h>
 #include <dns/time.h>

 #include "dst_internal.h"
@@ -106,6 +107,8 @@ static struct parse_map map[] = { { TAG_RSA_MODULUS, "Modulus:" },
 				  { TAG_EDDSA_ENGINE, "Engine:" },
 				  { TAG_EDDSA_LABEL, "Label:" },

+				  { TAG_MLDSA_PRIVATEKEY, "PrivateKey:" },
+
 				  { TAG_HMACMD5_KEY, "Key:" },
 				  { TAG_HMACMD5_BITS, "Bits:" },

@@ -290,6 +293,27 @@ check_eddsa(const dst_private_t *priv, bool external) {
 	return ok ? ISC_R_SUCCESS : DST_R_INVALIDPRIVATEKEY;
 }

+static isc_result_t
+check_mldsa(const dst_private_t *priv, bool external) {
+	if (external) {
+		return priv->nelements == 0 ? ISC_R_SUCCESS
+					    : DST_R_INVALIDPRIVATEKEY;
+	}
+
+	/*
+	 * The private key is the ML-DSA seed and nothing else;
+	 * opensslmldsa_parse() relies on this.
+	 */
+	if (priv->nelements != MLDSA_NTAGS ||
+	    priv->elements[0].tag != TAG_MLDSA_PRIVATEKEY ||
+	    priv->elements[0].length != DNS_KEY_MLDSA44SEEDSIZE)
+	{
+		return DST_R_INVALIDPRIVATEKEY;
+	}
+
+	return ISC_R_SUCCESS;
+}
+
 static isc_result_t
 check_hmac_md5(const dst_private_t *priv, bool old) {
 	int i, j;
@@ -360,6 +384,8 @@ check_data(const dst_private_t *priv, const unsigned int alg, bool old,
 	case DST_ALG_ED25519:
 	case DST_ALG_ED448:
 		return check_eddsa(priv, external);
+	case DST_ALG_MLDSA44:
+		return check_mldsa(priv, external);
 	case DST_ALG_HMACMD5:
 		return check_hmac_md5(priv, old);
 	case DST_ALG_HMACSHA1:
@@ -676,6 +702,9 @@ dst__privstruct_writefile(const dst_key_t *key, const dst_private_t *priv,
 	case DST_ALG_ED448:
 		fprintf(fp, "(ED448)\n");
 		break;
+	case DST_ALG_MLDSA44:
+		fprintf(fp, "(MLDSA44)\n");
+		break;
 	case DST_ALG_HMACMD5:
 		fprintf(fp, "(HMAC_MD5)\n");
 		break;
diff --git a/lib/dns/dst_parse.h b/lib/dns/dst_parse.h
index ddf707854a..3587c29943 100644
--- a/lib/dns/dst_parse.h
+++ b/lib/dns/dst_parse.h
@@ -67,6 +67,9 @@
 #define TAG_EDDSA_ENGINE     ((DST_ALG_ED25519 << TAG_SHIFT) + 1)
 #define TAG_EDDSA_LABEL	     ((DST_ALG_ED25519 << TAG_SHIFT) + 2)

+#define MLDSA_NTAGS	     1
+#define TAG_MLDSA_PRIVATEKEY ((DST_ALG_MLDSA44 << TAG_SHIFT) + 0)
+
 #define OLD_HMACMD5_NTAGS 1
 #define HMACMD5_NTAGS	  2
 #define TAG_HMACMD5_KEY	  ((DST_ALG_HMACMD5 << TAG_SHIFT) + 0)
diff --git a/lib/dns/include/dns/keyvalues.h b/lib/dns/include/dns/keyvalues.h
index ef3ded5d2e..437b090c13 100644
--- a/lib/dns/include/dns/keyvalues.h
+++ b/lib/dns/include/dns/keyvalues.h
@@ -57,6 +57,7 @@ enum {
 	DNS_KEYALG_ECDSA384 = 14,
 	DNS_KEYALG_ED25519 = 15,
 	DNS_KEYALG_ED448 = 16,
+	DNS_KEYALG_MLDSA44 = 18,
 	DNS_KEYALG_INDIRECT = 252,
 	DNS_KEYALG_PRIVATEDNS = 253,
 	DNS_KEYALG_PRIVATEOID = 254, /*%< Key begins with OID giving alg */
@@ -82,3 +83,8 @@ enum {

 #define DNS_KEY_ED448SIZE 57
 #define DNS_SIG_ED448SIZE 114
+
+/* draft-westerbaan-dnssec-mldsa */
+#define DNS_KEY_MLDSA44SIZE	1312
+#define DNS_SIG_MLDSA44SIZE	2420
+#define DNS_KEY_MLDSA44SEEDSIZE 32
diff --git a/lib/dns/include/dst/dst.h b/lib/dns/include/dst/dst.h
index 4abff90182..0ad87b2acc 100644
--- a/lib/dns/include/dst/dst.h
+++ b/lib/dns/include/dst/dst.h
@@ -93,6 +93,7 @@ typedef enum dst_algorithm {
 	DST_ALG_ECDSA384 = 14,
 	DST_ALG_ED25519 = 15,
 	DST_ALG_ED448 = 16,
+	DST_ALG_MLDSA44 = 18,

 	/*
 	 * Do not renumber HMAC algorithms as they are used externally to named
diff --git a/lib/dns/kasp.c b/lib/dns/kasp.c
index 72b51be2c2..49087d3922 100644
--- a/lib/dns/kasp.c
+++ b/lib/dns/kasp.c
@@ -456,6 +456,9 @@ dns_kasp_key_size(dns_kasp_key_t *key) {
 	case DST_ALG_ED448:
 		size = 456;
 		break;
+	case DST_ALG_MLDSA44:
+		size = DNS_KEY_MLDSA44SIZE * 8;
+		break;
 	default:
 		/* unsupported */
 		break;
diff --git a/lib/dns/meson.build b/lib/dns/meson.build
index 2248b0588a..6e5676e410 100644
--- a/lib/dns/meson.build
+++ b/lib/dns/meson.build
@@ -30,6 +30,10 @@ dns_header_gen = executable(

 subdir('include')

+if config.has('HAVE_OPENSSL_MLDSA44')
+    dns_srcset.add(files('opensslmldsa_link.c'))
+endif
+
 dns_srcset.add(dns_gen_headers)
 dns_srcset.add(when: dnstap_dep, if_true: files('dnstap.c'))
 dns_srcset.add(when: maxminddb_dep, if_true: files('geoip2.c'))
diff --git a/lib/dns/opensslmldsa_link.c b/lib/dns/opensslmldsa_link.c
new file mode 100644
index 0000000000..15305f20fb
--- /dev/null
+++ b/lib/dns/opensslmldsa_link.c
@@ -0,0 +1,494 @@
+/*
+ * Copyright (C) Internet Systems Consortium, Inc. ("ISC")
+ *
+ * SPDX-License-Identifier: MPL-2.0
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, you can obtain one at https://mozilla.org/MPL/2.0/.
+ *
+ * See the COPYRIGHT file distributed with this work for additional
+ * information regarding copyright ownership.
+ */
+
+/*! \file */
+
+#include <openssl/core_names.h>
+#include <openssl/err.h>
+#include <openssl/evp.h>
+#include <openssl/params.h>
+#include <openssl/rand.h>
+
+#include <isc/buffer.h>
+#include <isc/mem.h>
+#include <isc/result.h>
+#include <isc/safe.h>
+#include <isc/util.h>
+
+#include <dns/keyvalues.h>
+
+#include "dst_internal.h"
+#include "dst_openssl.h"
+#include "dst_parse.h"
+
+/*
+ * draft-westerbaan-dnssec-mldsa uses pure ML-DSA-44 with an empty context.
+ * ML-DSA hashes the message with SHAKE256 before signing it (FIPS 204
+ * Algorithm 7 step 6, Algorithm 8 step 7):
+ *
+ *	tr = H(pk, 64)
+ *	mu = H(tr || M', 64), where M' = 0 || |ctx| || ctx || M
+ *
+ * so mu is computed incrementally as the data is added, and the provider
+ * is handed mu instead of the message.  Hedged signing is left at the
+ * OpenSSL default.
+ */
+#define MLDSA_TR_SIZE 64
+#define MLDSA_MU_SIZE 64
+
+static const unsigned char mldsa_prefix[] = { 0x00, 0x00 };
+
+static void
+opensslmldsa__setkey(dst_key_t *key, EVP_PKEY *pk, EVP_PKEY *sk,
+		     uint8_t *seed) {
+	key->key_size = DNS_KEY_MLDSA44SIZE * 8;
+	key->keydata.pkeypair.pub = pk;
+	key->keydata.pkeypair.priv = sk;
+	key->keydata.pkeypair.seed = seed;
+}
+
+static isc_result_t
+opensslmldsa__getpk(dst_key_t *key, unsigned char *pk, size_t prlen) {
+	size_t len = prlen;
+	int r = EVP_PKEY_get_raw_public_key(key->keydata.pkeypair.pub, pk,
+					    &len);
+	if (r != 1) {
+		return dst__openssl_toresult(DST_R_INVALIDPUBLICKEY);
+	}
+	INSIST(len == prlen);
+
+	return ISC_R_SUCCESS;
+}
+
+static isc_result_t
+opensslmldsa__addpk(dst_context_t *dctx, EVP_MD_CTX *ctx, unsigned char *pk,
+		    size_t pklen, unsigned char *tr, size_t trlen) {
+	int r = EVP_DigestInit_ex(ctx, isc__crypto_md[ISC_MD_SHAKE256], NULL);
+	if (r != 1) {
+		goto cleanup;
+	}
+
+	r = EVP_DigestUpdate(ctx, pk, pklen);
+	if (r != 1) {
+		goto cleanup;
+	}
+
+	r = EVP_DigestFinalXOF(ctx, tr, trlen);
+	if (r != 1) {
+		goto cleanup;
+	}
+
+	return ISC_R_SUCCESS;
+
+cleanup:
+	return dst__openssl_toresult3(dctx->category, "SHAKE256(pk)",
+				      ISC_R_FAILURE);
+}
+
+static isc_result_t
+opensslmldsa__addtr(dst_context_t *dctx, EVP_MD_CTX *ctx, unsigned char *tr,
+		    size_t trlen) {
+	int r = EVP_DigestInit_ex(ctx, isc__crypto_md[ISC_MD_SHAKE256], NULL);
+	if (r != 1) {
+		goto cleanup;
+	}
+	r = EVP_DigestUpdate(ctx, tr, trlen);
+	if (r != 1) {
+		goto cleanup;
+	}
+	r = EVP_DigestUpdate(ctx, mldsa_prefix, sizeof(mldsa_prefix));
+	if (r != 1) {
+		goto cleanup;
+	}
+
+	return ISC_R_SUCCESS;
+cleanup:
+	return dst__openssl_toresult3(dctx->category, "SHAKE256(tr)",
+				      ISC_R_FAILURE);
+}
+
+static isc_result_t
+opensslmldsa_createctx(dst_key_t *key, dst_context_t *dctx) {
+	REQUIRE(key->key_alg == DST_ALG_MLDSA44);
+
+	isc_result_t result = ISC_R_SUCCESS;
+	EVP_MD_CTX *ctx = NULL;
+	unsigned char pk[DNS_KEY_MLDSA44SIZE];
+	unsigned char tr[MLDSA_TR_SIZE];
+
+	ctx = EVP_MD_CTX_new();
+	if (ctx == NULL) {
+		return dst__openssl_toresult(ISC_R_NOMEMORY);
+	}
+
+	CHECK(opensslmldsa__getpk(key, pk, sizeof(pk)));
+	CHECK(opensslmldsa__addpk(dctx, ctx, pk, sizeof(pk), tr, sizeof(tr)));
+	CHECK(opensslmldsa__addtr(dctx, ctx, tr, sizeof(tr)));
+
+	dctx->ctxdata.evp_md_ctx = MOVE_OWNERSHIP(ctx);
+
+cleanup:
+	EVP_MD_CTX_free(ctx);
+	return result;
+}
+
+static void
+opensslmldsa_destroyctx(dst_context_t *dctx) {
+	EVP_MD_CTX *ctx = MOVE_OWNERSHIP(dctx->ctxdata.evp_md_ctx);
+	EVP_MD_CTX_free(ctx);
+}
+
+static isc_result_t
+opensslmldsa_adddata(dst_context_t *dctx, const isc_region_t *data) {
+	int r = EVP_DigestUpdate(dctx->ctxdata.evp_md_ctx, data->base,
+				 data->length);
+	if (r != 1) {
+		return dst__openssl_toresult3(
+			dctx->category, "EVP_DigestUpdate", ISC_R_FAILURE);
+	}
+	return ISC_R_SUCCESS;
+}
+
+static isc_result_t
+opensslmldsa_sign(dst_context_t *dctx, isc_buffer_t *sig) {
+	isc_result_t result = ISC_R_SUCCESS;
+	isc_region_t target;
+	EVP_MD_CTX *ctx = NULL;
+	EVP_PKEY *pkey = dctx->key->keydata.pkeypair.priv;
+	unsigned char mu[MLDSA_MU_SIZE];
+	size_t siglen = DNS_SIG_MLDSA44SIZE;
+	int external_mu = 1;
+	const OSSL_PARAM params[] = {
+		OSSL_PARAM_int(OSSL_SIGNATURE_PARAM_MU, &external_mu),
+		OSSL_PARAM_END,
+	};
+
+	if (pkey == NULL) {
+		return DST_R_NULLKEY;
+	}
+	isc_buffer_availableregion(sig, &target);
+	if (target.length < siglen) {
+		return ISC_R_NOSPACE;
+	}
+
+	ctx = EVP_MD_CTX_new();
+	if (ctx == NULL) {
+		return dst__openssl_toresult(ISC_R_NOMEMORY);
+	}
+
+	int r = EVP_DigestFinalXOF(dctx->ctxdata.evp_md_ctx, mu, sizeof(mu));
+	if (r != 1) {
+		CLEANUP(dst__openssl_toresult3(dctx->category,
+					       "EVP_DigestFinalXOF",
+					       DST_R_SIGNFAILURE));
+	}
+
+	r = EVP_DigestSignInit_ex(ctx, NULL, NULL, NULL, NULL, pkey, params);
+	if (r != 1) {
+		CLEANUP(dst__openssl_toresult3(dctx->category,
+					       "EVP_DigestSignInit_ex",
+					       DST_R_SIGNFAILURE));
+	}
+
+	r = EVP_DigestSign(ctx, target.base, &siglen, mu, sizeof(mu));
+	if (r != 1) {
+		CLEANUP(dst__openssl_toresult3(dctx->category, "EVP_DigestSign",
+					       DST_R_SIGNFAILURE));
+	}
+	INSIST(siglen == DNS_SIG_MLDSA44SIZE);
+	isc_buffer_add(sig, siglen);
+
+cleanup:
+	EVP_MD_CTX_free(ctx);
+	return result;
+}
+
+static isc_result_t
+opensslmldsa_verify(dst_context_t *dctx, const isc_region_t *sig) {
+	isc_result_t result = ISC_R_SUCCESS;
+	EVP_MD_CTX *ctx = NULL;
+	EVP_PKEY *pkey = dctx->key->keydata.pkeypair.pub;
+	unsigned char mu[MLDSA_MU_SIZE];
+	int external_mu = 1;
+	const OSSL_PARAM params[] = {
+		OSSL_PARAM_int(OSSL_SIGNATURE_PARAM_MU, &external_mu),
+		OSSL_PARAM_END,
+	};
+
+	if (sig->length != DNS_SIG_MLDSA44SIZE) {
+		return DST_R_VERIFYFAILURE;
+	}
+
+	ctx = EVP_MD_CTX_new();
+	if (ctx == NULL) {
+		return dst__openssl_toresult(ISC_R_NOMEMORY);
+	}
+
+	int r = EVP_DigestFinalXOF(dctx->ctxdata.evp_md_ctx, mu, sizeof(mu));
+	if (r != 1) {
+		CLEANUP(dst__openssl_toresult3(dctx->category,
+					       "EVP_DigestFinalXOF",
+					       DST_R_VERIFYFAILURE));
+	}
+
+	r = EVP_DigestVerifyInit_ex(ctx, NULL, NULL, NULL, NULL, pkey, params);
+	if (r != 1) {
+		CLEANUP(dst__openssl_toresult3(dctx->category,
+					       "EVP_DigestVerifyInit_ex",
+					       DST_R_VERIFYFAILURE));
+	}
+
+	r = EVP_DigestVerify(ctx, sig->base, sig->length, mu, sizeof(mu));
+	if (r != 1) {
+		CLEANUP(dst__openssl_toresult(DST_R_VERIFYFAILURE));
+	}
+
+cleanup:
+	EVP_MD_CTX_free(ctx);
+	return result;
+}
+
+static void
+opensslmldsa_destroy(dst_key_t *key) {
+	if (key->keydata.pkeypair.seed != NULL) {
+		isc_safe_memwipe(key->keydata.pkeypair.seed,
+				 DNS_KEY_MLDSA44SEEDSIZE);
+		isc_mem_put(key->mctx, key->keydata.pkeypair.seed,
+			    DNS_KEY_MLDSA44SEEDSIZE);
+	}
+	dst__openssl_keypair_destroy(key);
+}
+
+static isc_result_t
+opensslmldsa_generate(dst_key_t *key, int unused ISC_ATTR_UNUSED,
+		      void (*callback ISC_ATTR_UNUSED)(int)) {
+	isc_result_t result = ISC_R_SUCCESS;
+	EVP_PKEY_CTX *ctx = NULL;
+	EVP_PKEY *pkey = NULL;
+	uint8_t *seed = NULL;
+	OSSL_PARAM params[2];
+
+	if (key->label != NULL) {
+		return ISC_R_NOTIMPLEMENTED;
+	}
+
+	ctx = EVP_PKEY_CTX_new_from_name(NULL, "ML-DSA-44", NULL);
+	if (ctx == NULL) {
+		return dst__openssl_toresult2("EVP_PKEY_CTX_new_from_name",
+					      DST_R_OPENSSLFAILURE);
+	}
+	int r = EVP_PKEY_keygen_init(ctx);
+	if (r != 1) {
+		CLEANUP(dst__openssl_toresult2("EVP_PKEY_keygen_init",
+					       DST_R_OPENSSLFAILURE));
+	}
+
+	/*
+	 * Draw the seed from the same DRBG the provider would use and let
+	 * the provider generate the key from it, so that we know the seed.
+	 */
+	seed = isc_mem_get(key->mctx, DNS_KEY_MLDSA44SEEDSIZE);
+	r = RAND_priv_bytes_ex(NULL, seed, DNS_KEY_MLDSA44SEEDSIZE, 0);
+	if (r != 1) {
+		CLEANUP(dst__openssl_toresult2("RAND_priv_bytes_ex",
+					       DST_R_OPENSSLFAILURE));
+	}
+	params[0] = OSSL_PARAM_construct_octet_string(
+		OSSL_PKEY_PARAM_ML_DSA_SEED, seed, DNS_KEY_MLDSA44SEEDSIZE);
+	params[1] = OSSL_PARAM_construct_end();
+	r = EVP_PKEY_CTX_set_params(ctx, params);
+	if (r != 1) {
+		CLEANUP(dst__openssl_toresult2("EVP_PKEY_CTX_set_params",
+					       DST_R_OPENSSLFAILURE));
+	}
+
+	r = EVP_PKEY_generate(ctx, &pkey);
+	if (r != 1) {
+		CLEANUP(dst__openssl_toresult2("EVP_PKEY_generate",
+					       DST_R_OPENSSLFAILURE));
+	}
+
+	opensslmldsa__setkey(key, pkey, pkey, seed);
+	(void)MOVE_OWNERSHIP(pkey);
+	(void)MOVE_OWNERSHIP(seed);
+
+cleanup:
+	if (seed != NULL) {
+		isc_safe_memwipe(seed, DNS_KEY_MLDSA44SEEDSIZE);
+		isc_mem_put(key->mctx, seed, DNS_KEY_MLDSA44SEEDSIZE);
+	}
+	EVP_PKEY_CTX_free(ctx);
+	return result;
+}
+
+static isc_result_t
+opensslmldsa_todns(const dst_key_t *key, isc_buffer_t *data) {
+	isc_region_t target;
+	size_t len = DNS_KEY_MLDSA44SIZE;
+
+	isc_buffer_availableregion(data, &target);
+	if (target.length < len) {
+		return ISC_R_NOSPACE;
+	}
+	if (EVP_PKEY_get_raw_public_key(key->keydata.pkeypair.pub, target.base,
+					&len) != 1)
+	{
+		return dst__openssl_toresult(DST_R_INVALIDPUBLICKEY);
+	}
+	INSIST(len == DNS_KEY_MLDSA44SIZE);
+	isc_buffer_add(data, len);
+	return ISC_R_SUCCESS;
+}
+
+static isc_result_t
+opensslmldsa_fromdns(dst_key_t *key, isc_buffer_t *data) {
+	isc_region_t source;
+	EVP_PKEY *pkey = NULL;
+
+	isc_buffer_remainingregion(data, &source);
+	if (source.length != DNS_KEY_MLDSA44SIZE) {
+		return DST_R_INVALIDPUBLICKEY;
+	}
+	pkey = EVP_PKEY_new_raw_public_key_ex(NULL, "ML-DSA-44", NULL,
+					      source.base, source.length);
+	if (pkey == NULL) {
+		return dst__openssl_toresult(DST_R_INVALIDPUBLICKEY);
+	}
+	isc_buffer_forward(data, source.length);
+	opensslmldsa__setkey(key, pkey, NULL, NULL);
+	return ISC_R_SUCCESS;
+}
+
+static isc_result_t
+opensslmldsa_tofile(const dst_key_t *key, const char *directory) {
+	dst_private_t priv = { 0 };
+
+	if (key->external) {
+		return dst__privstruct_writefile(key, &priv, directory);
+	}
+	if (key->keydata.pkeypair.seed == NULL) {
+		return DST_R_NULLKEY;
+	}
+
+	priv.nelements = 1;
+	priv.elements[0].tag = TAG_MLDSA_PRIVATEKEY;
+	priv.elements[0].length = DNS_KEY_MLDSA44SEEDSIZE;
+	priv.elements[0].data = key->keydata.pkeypair.seed;
+	return dst__privstruct_writefile(key, &priv, directory);
+}
+
+static isc_result_t
+opensslmldsa_parse(dst_key_t *key, isc_lex_t *lexer, dst_key_t *pub) {
+	isc_result_t result = ISC_R_SUCCESS;
+	dst_private_t priv;
+	EVP_PKEY_CTX *ctx = NULL;
+	EVP_PKEY *pkey = NULL;
+	OSSL_PARAM params[2];
+	uint8_t *seed = NULL;
+
+	CHECK(dst__privstruct_parse(key, DST_ALG_MLDSA44, lexer, key->mctx,
+				    &priv));
+	if (key->external) {
+		if (pub == NULL) {
+			CLEANUP(DST_R_INVALIDPRIVATEKEY);
+		}
+		key->keydata.pkeypair = pub->keydata.pkeypair;
+		pub->keydata.pkeypair.priv = NULL;
+		pub->keydata.pkeypair.pub = NULL;
+		pub->keydata.pkeypair.seed = NULL;
+		CLEANUP(ISC_R_SUCCESS);
+	}
+
+	/* check_mldsa() requires exactly one 32-byte seed. */
+	if (priv.elements[0].length != DNS_KEY_MLDSA44SEEDSIZE) {
+		CLEANUP(DST_R_INVALIDPRIVATEKEY);
+	}
+	seed = isc_mem_get(key->mctx, DNS_KEY_MLDSA44SEEDSIZE);
+	memmove(seed, priv.elements[0].data, priv.elements[0].length);
+	params[0] = OSSL_PARAM_construct_octet_string(
+		OSSL_PKEY_PARAM_ML_DSA_SEED, seed, priv.elements[0].length);
+	params[1] = OSSL_PARAM_construct_end();
+	ctx = EVP_PKEY_CTX_new_from_name(NULL, "ML-DSA-44", NULL);
+	if (ctx == NULL) {
+		CLEANUP(dst__openssl_toresult2("EVP_PKEY_CTX_new_from_name",
+					       DST_R_INVALIDPRIVATEKEY));
+	};
+
+	int r = EVP_PKEY_fromdata_init(ctx);
+	if (r != 1) {
+		CLEANUP(dst__openssl_toresult2("EVP_PKEY_fromdata_init",
+					       DST_R_INVALIDPRIVATEKEY));
+	}
+
+	r = EVP_PKEY_fromdata(ctx, &pkey, EVP_PKEY_KEYPAIR, params);
+	if (r != 1) {
+		CLEANUP(dst__openssl_toresult2("EVP_PKEY_fromdata",
+					       DST_R_INVALIDPRIVATEKEY));
+	}
+
+	if (pub != NULL) {
+		r = EVP_PKEY_eq(pkey, pub->keydata.pkeypair.pub);
+		if (r != 1) {
+			CLEANUP(DST_R_INVALIDPRIVATEKEY);
+		}
+	}
+
+	opensslmldsa__setkey(key, pkey, pkey, seed);
+	(void)MOVE_OWNERSHIP(pkey);
+	(void)MOVE_OWNERSHIP(seed);
+
+cleanup:
+	if (seed != NULL) {
+		isc_safe_memwipe(seed, DNS_KEY_MLDSA44SEEDSIZE);
+		isc_mem_put(key->mctx, seed, DNS_KEY_MLDSA44SEEDSIZE);
+	}
+	EVP_PKEY_free(pkey);
+
+	EVP_PKEY_CTX_free(ctx);
+	dst__privstruct_free(&priv, key->mctx);
+	isc_safe_memwipe(&priv, sizeof(priv));
+	return result;
+}
+
+static dst_func_t opensslmldsa_functions = {
+	.createctx = opensslmldsa_createctx,
+	.destroyctx = opensslmldsa_destroyctx,
+	.adddata = opensslmldsa_adddata,
+	.sign = opensslmldsa_sign,
+	.verify = opensslmldsa_verify,
+	.compare = dst__openssl_keypair_compare,
+	.generate = opensslmldsa_generate,
+	.isprivate = dst__openssl_keypair_isprivate,
+	.destroy = opensslmldsa_destroy,
+	.todns = opensslmldsa_todns,
+	.fromdns = opensslmldsa_fromdns,
+	.tofile = opensslmldsa_tofile,
+	.parse = opensslmldsa_parse,
+};
+
+void
+dst__opensslmldsa_init(dst_func_t **funcp) {
+	REQUIRE(funcp != NULL);
+
+	/* Respect the configured providers and default property query. */
+	EVP_KEYMGMT *keymgmt = EVP_KEYMGMT_fetch(NULL, "ML-DSA-44", NULL);
+	EVP_SIGNATURE *signature = EVP_SIGNATURE_fetch(NULL, "ML-DSA-44", NULL);
+	if (keymgmt != NULL && signature != NULL &&
+	    isc__crypto_md[ISC_MD_SHAKE256] != NULL)
+	{
+		*funcp = &opensslmldsa_functions;
+	}
+	EVP_KEYMGMT_free(keymgmt);
+	EVP_SIGNATURE_free(signature);
+	ERR_clear_error();
+}
diff --git a/lib/dns/rcode.c b/lib/dns/rcode.c
index b58b330d75..adce491774 100644
--- a/lib/dns/rcode.c
+++ b/lib/dns/rcode.c
@@ -107,6 +107,7 @@
 		{ DNS_KEYALG_ECDSA384, "ECDSA384", 0 },         \
 		{ DNS_KEYALG_ED25519, "ED25519", 0 },           \
 		{ DNS_KEYALG_ED448, "ED448", 0 },               \
+		{ DNS_KEYALG_MLDSA44, "MLDSA44", 0 },           \
 		{ DNS_KEYALG_INDIRECT, "INDIRECT", 0 },         \
 		{ DNS_KEYALG_PRIVATEDNS, "PRIVATEDNS", 0 },     \
 		{ DNS_KEYALG_PRIVATEOID, "PRIVATEOID", 0 }, SENTINEL
diff --git a/lib/isc/crypto/ossl3.c b/lib/isc/crypto/ossl3.c
index 51d94b304f..a8641e4454 100644
--- a/lib/isc/crypto/ossl3.c
+++ b/lib/isc/crypto/ossl3.c
@@ -95,6 +95,8 @@ static isc_constregion_t md_to_name[ISC_MD_MAX] = {
 			    .length = sizeof("SHA2-384") - 1 },
 	[ISC_MD_SHA512] = { .base = "SHA2-512",
 			    .length = sizeof("SHA2-512") - 1 },
+	[ISC_MD_SHAKE256] = { .base = "SHAKE-256",
+			      .length = sizeof("SHAKE-256") - 1 },
 };

 static OSSL_PARAM md_to_hmac_params[ISC_MD_MAX][2] = {
@@ -123,6 +125,8 @@ static OSSL_PARAM md_to_hmac_params[ISC_MD_MAX][2] = {
 		OSSL_PARAM_utf8_string(OSSL_MAC_PARAM_DIGEST, UNCONST("SHA2-512"), sizeof("SHA2-512") - 1),
 		OSSL_PARAM_END,
 	},
+	[ISC_MD_SHAKE256] = {
+		OSSL_PARAM_utf8_string(OSSL_MAC_PARAM_DIGEST, UNCONST("SHAKE-256"), sizeof("SHAKE-256") - 1), OSSL_PARAM_END, },
 };

 #define md_register_algorithm(alg)                                             \
@@ -152,6 +156,9 @@ register_algorithms(void) {
 	md_register_algorithm(SHA256);
 	md_register_algorithm(SHA384);
 	md_register_algorithm(SHA512);
+#if HAVE_OPENSSL_MLDSA44
+	md_register_algorithm(SHAKE256);
+#endif

 	INSIST(evp_aes_128_gcm == NULL);
 	evp_aes_128_gcm = EVP_CIPHER_fetch(NULL, "AES-128-GCM", NULL);
diff --git a/lib/isc/crypto/ossl_common.c b/lib/isc/crypto/ossl_common.c
index 965bbb7fab..e5752385e1 100644
--- a/lib/isc/crypto/ossl_common.c
+++ b/lib/isc/crypto/ossl_common.c
@@ -20,7 +20,8 @@
 #include <isc/util.h>

 EVP_MD *isc__crypto_md[] = {
-	[ISC_MD_UNKNOWN] = NULL, [ISC_MD_MD5] = NULL,	 [ISC_MD_SHA1] = NULL,
-	[ISC_MD_SHA224] = NULL,	 [ISC_MD_SHA256] = NULL, [ISC_MD_SHA384] = NULL,
-	[ISC_MD_SHA512] = NULL,
+	[ISC_MD_UNKNOWN] = NULL, [ISC_MD_MD5] = NULL,
+	[ISC_MD_SHA1] = NULL,	 [ISC_MD_SHA224] = NULL,
+	[ISC_MD_SHA256] = NULL,	 [ISC_MD_SHA384] = NULL,
+	[ISC_MD_SHA512] = NULL,	 [ISC_MD_SHAKE256] = NULL,
 };
diff --git a/lib/isc/include/isc/md.h b/lib/isc/include/isc/md.h
index f8f89bba68..8fb7c01e9a 100644
--- a/lib/isc/include/isc/md.h
+++ b/lib/isc/include/isc/md.h
@@ -42,24 +42,26 @@ typedef enum isc_md_type {
 	ISC_MD_SHA256 = 0x04,
 	ISC_MD_SHA384 = 0x05,
 	ISC_MD_SHA512 = 0x06,
-	ISC_MD_MAX = 0x07,
+	ISC_MD_SHAKE256 = 0x07,
+	ISC_MD_MAX = 0x08,
 } isc_md_type_t;

-#define ISC_MD5_DIGESTLENGTH	16
-#define ISC_MD5_BLOCK_LENGTH	64
-#define ISC_SHA1_DIGESTLENGTH	20
-#define ISC_SHA1_BLOCK_LENGTH	64
-#define ISC_SHA224_DIGESTLENGTH 28
-#define ISC_SHA224_BLOCK_LENGTH 64
-#define ISC_SHA256_DIGESTLENGTH 32
-#define ISC_SHA256_BLOCK_LENGTH 64
-#define ISC_SHA384_DIGESTLENGTH 48
-#define ISC_SHA384_BLOCK_LENGTH 128
-#define ISC_SHA512_DIGESTLENGTH 64
-#define ISC_SHA512_BLOCK_LENGTH 128
+#define ISC_MD5_DIGESTLENGTH	  16
+#define ISC_MD5_BLOCK_LENGTH	  64
+#define ISC_SHA1_DIGESTLENGTH	  20
+#define ISC_SHA1_BLOCK_LENGTH	  64
+#define ISC_SHA224_DIGESTLENGTH	  28
+#define ISC_SHA224_BLOCK_LENGTH	  64
+#define ISC_SHA256_DIGESTLENGTH	  32
+#define ISC_SHA256_BLOCK_LENGTH	  64
+#define ISC_SHA384_DIGESTLENGTH	  48
+#define ISC_SHA384_BLOCK_LENGTH	  128
+#define ISC_SHA512_DIGESTLENGTH	  64
+#define ISC_SHA512_BLOCK_LENGTH	  128
+#define ISC_SHAKE256_BLOCK_LENGTH 136

 #define ISC_MAX_MD_SIZE	   64U	/* EVP_MAX_MD_SIZE */
-#define ISC_MAX_BLOCK_SIZE 128U /* ISC_SHA512_BLOCK_LENGTH */
+#define ISC_MAX_BLOCK_SIZE 136U /* ISC_SHAKE256_BLOCK_LENGTH */

 /**
  * isc_md:
diff --git a/lib/isccfg/kaspconf.c b/lib/isccfg/kaspconf.c
index 3b1913d83c..5008a6db1a 100644
--- a/lib/isccfg/kaspconf.c
+++ b/lib/isccfg/kaspconf.c
@@ -349,6 +349,7 @@ cfg_kaspkey_fromconfig(const cfg_obj_t *config, dns_kasp_t *kasp,
 			case DST_ALG_ECDSA384:
 			case DST_ALG_ED25519:
 			case DST_ALG_ED448:
+			case DST_ALG_MLDSA44:
 				if (log_errors) {
 					kaspcfg_log(
 						obj, ISC_LOG_WARNING,
diff --git a/meson.build b/meson.build
index 7c70fd6c3a..d45aedfff8 100644
--- a/meson.build
+++ b/meson.build
@@ -727,6 +727,11 @@ if cc.has_header_symbol('openssl/evp.h', 'NID_ED448', dependencies: openssl_dep)
     config.set('HAVE_OPENSSL_ED448', 1)
 endif

+# Check for ML-DSA support (requires OpenSSL 3.5+ with ML-DSA support)
+if cc.has_header_symbol('openssl/evp.h', 'EVP_PKEY_ML_DSA_44', dependencies: openssl_dep)
+    config.set('HAVE_OPENSSL_MLDSA44', 1)
+endif
+
 foreach fn, header : {
     'ERR_get_error_all': '#include <openssl/err.h>',
     'BIO_read_ex': '#include <openssl/bio.h>',
diff --git a/tests/dns/dst_test.c b/tests/dns/dst_test.c
index 0c97dfba75..3dbb8464a7 100644
--- a/tests/dns/dst_test.c
+++ b/tests/dns/dst_test.c
@@ -26,6 +26,13 @@
  * redefined malloc in cmocka.h.
  */
 #include <openssl/err.h>
+#ifdef HAVE_OPENSSL_MLDSA44
+#include <limits.h>
+
+#include <openssl/core_names.h>
+#include <openssl/params.h>
+#include <openssl/provider.h>
+#endif

 #define UNIT_TESTING
 #include <cmocka.h>
@@ -259,7 +266,7 @@ check_sig(const char *datapath, const char *sigpath, const char *keyname,
 	size_t rval, len;
 	FILE *fp;
 	dst_key_t *key = NULL;
-	unsigned char sig[512];
+	unsigned char sig[DNS_SIG_MLDSA44SIZE + 1];
 	unsigned char *p;
 	unsigned char *data;
 	off_t size;
@@ -357,11 +364,12 @@ check_sig(const char *datapath, const char *sigpath, const char *keyname,
 		isc_region_t r;
 		isc_buffer_usedregion(&sigb, &r);

-		char hexbuf[4096] = { 0 };
+		char hexbuf[sizeof(sigbuf2) * 2 + 1] = { 0 };
 		isc_buffer_t hb;
 		isc_buffer_init(&hb, hexbuf, sizeof(hexbuf));

-		isc_hex_totext(&r, 0, "", &hb);
+		result2 = isc_hex_totext(&r, 0, "", &hb);
+		assert_int_equal(result2, ISC_R_SUCCESS);

 		fprintf(stderr, "# %s:\n# %s\n", sigpath, hexbuf);
 	}
@@ -385,6 +393,13 @@ ISC_RUN_TEST_IMPL(sig_test) {
 		dns_secalg_t alg;
 		bool expect;
 	} testcases[] = {
+		/* Published DNSSEC example, including the private seed. */
+		{ TESTS_DIR "/testdata/dst/mldsa.data",
+		  TESTS_DIR "/testdata/dst/mldsa.sig", "example.com.", 59829,
+		  DST_ALG_MLDSA44, true },
+		{ TESTS_DIR "/testdata/dst/test1.data",
+		  TESTS_DIR "/testdata/dst/mldsa.sig", "example.com.", 59829,
+		  DST_ALG_MLDSA44, false },
 		{ TESTS_DIR "/testdata/dst/test1.data",
 		  TESTS_DIR "/testdata/dst/test1.ecdsa256sig", "test.", 49130,
 		  DST_ALG_ECDSA256, true },
@@ -621,12 +636,230 @@ ISC_RUN_TEST_IMPL(ecdsa_determinism_test) {
 	dst_key_free(&key);
 }

+static isc_result_t
+mldsa_verify(dst_key_t *key, isc_region_t *data, isc_region_t *sig) {
+	dst_context_t *ctx = NULL;
+	isc_result_t result;
+
+	result = dst_context_create(key, isc_g_mctx, DNS_LOGCATEGORY_GENERAL,
+				    false, &ctx);
+	assert_int_equal(result, ISC_R_SUCCESS);
+	assert_int_equal(dst_context_adddata(ctx, data), ISC_R_SUCCESS);
+	result = dst_context_verify(ctx, sig);
+	dst_context_destroy(&ctx);
+	return result;
+}
+
+ISC_RUN_TEST_IMPL(mldsa_wire) {
+	dns_fixedname_t fname;
+	dns_name_t *name = dns_fixedname_initname(&fname);
+	dst_key_t *key = NULL, *pub = NULL;
+	dst_context_t *ctx = NULL;
+	unsigned char wire[4 + DNS_KEY_MLDSA44SIZE + 1] = { 0 };
+	unsigned char signature[DNS_SIG_MLDSA44SIZE + 1] = { 0 };
+	unsigned char message[4096] = { 1, 2, 3, 4 };
+	isc_region_t data = { .base = message, .length = sizeof(message) };
+	isc_region_t sig;
+	isc_buffer_t buf;
+	unsigned int sigsize;
+
+	if (!dst_algorithm_supported(DST_ALG_MLDSA44)) {
+		skip();
+	}
+	assert_int_equal(dns_name_fromstring(name, "example.com.", dns_rootname,
+					     0, isc_g_mctx),
+			 ISC_R_SUCCESS);
+	assert_int_equal(dst_key_fromfile(name, 59829, DST_ALG_MLDSA44,
+					  DST_TYPE_PUBLIC | DST_TYPE_PRIVATE,
+					  TESTS_DIR "/testdata/dst", isc_g_mctx,
+					  &key),
+			 ISC_R_SUCCESS);
+	assert_int_equal(dst_key_size(key), 1312 * 8);
+	assert_int_equal(dst_key_sigsize(key, &sigsize), ISC_R_SUCCESS);
+	assert_int_equal(sigsize, 2420);
+
+	isc_buffer_init(&buf, wire, sizeof(wire));
+	assert_int_equal(dst_key_todns(key, &buf), ISC_R_SUCCESS);
+	assert_int_equal(isc_buffer_usedlength(&buf), 4 + 1312);
+	assert_int_equal(wire[3], 18);
+	assert_int_equal(dst_key_fromdns(name, dns_rdataclass_in, &buf,
+					 isc_g_mctx, &pub),
+			 ISC_R_SUCCESS);
+	assert_true(dst_key_pubcompare(key, pub, false));
+
+	/* Truncated and oversized public keys must not be accepted. */
+	unsigned int lengths[] = { 5, 4 + 1311, 4 + 1313 };
+	for (size_t i = 0; i < ARRAY_SIZE(lengths); i++) {
+		dst_key_t *bad = NULL;
+		isc_buffer_init(&buf, wire, sizeof(wire));
+		isc_buffer_add(&buf, lengths[i]);
+		assert_int_equal(dst_key_fromdns(name, dns_rdataclass_in, &buf,
+						 isc_g_mctx, &bad),
+				 DST_R_INVALIDPUBLICKEY);
+		assert_null(bad);
+	}
+
+	assert_int_equal(dst_context_create(key, isc_g_mctx,
+					    DNS_LOGCATEGORY_GENERAL, true,
+					    &ctx),
+			 ISC_R_SUCCESS);
+	/* Exercise hashing of multiple input regions into mu. */
+	isc_region_t first = { .base = message, .length = 20 };
+	isc_region_t rest = { .base = message + 20,
+			      .length = sizeof(message) - 20 };
+	assert_int_equal(dst_context_adddata(ctx, &first), ISC_R_SUCCESS);
+	assert_int_equal(dst_context_adddata(ctx, &rest), ISC_R_SUCCESS);
+	isc_buffer_init(&buf, signature, DNS_SIG_MLDSA44SIZE - 1);
+	assert_int_equal(dst_context_sign(ctx, &buf), ISC_R_NOSPACE);
+	isc_buffer_init(&buf, signature, sizeof(signature));
+	assert_int_equal(dst_context_sign(ctx, &buf), ISC_R_SUCCESS);
+	assert_int_equal(isc_buffer_usedlength(&buf), 2420);
+	dst_context_destroy(&ctx);
+	isc_buffer_usedregion(&buf, &sig);
+#ifdef HAVE_OPENSSL_MLDSA44
+	/* Signing over mu must match OpenSSL's one-shot pure ML-DSA. */
+	EVP_MD_CTX *oneshot = EVP_MD_CTX_new();
+	assert_non_null(oneshot);
+	assert_int_equal(
+		EVP_DigestVerifyInit_ex(oneshot, NULL, NULL, NULL, NULL,
+					pub->keydata.pkeypair.pub, NULL),
+		1);
+	assert_int_equal(EVP_DigestVerify(oneshot, sig.base, sig.length,
+					  data.base, data.length),
+			 1);
+	EVP_MD_CTX_free(oneshot);
+#endif
+	assert_int_equal(mldsa_verify(pub, &data, &sig), ISC_R_SUCCESS);
+	sig.length--;
+	assert_int_equal(mldsa_verify(pub, &data, &sig), DST_R_VERIFYFAILURE);
+	sig.length += 2;
+	assert_int_equal(mldsa_verify(pub, &data, &sig), DST_R_VERIFYFAILURE);
+	sig.length--;
+	signature[0] ^= 1;
+	assert_int_equal(mldsa_verify(pub, &data, &sig), DST_R_VERIFYFAILURE);
+
+#ifdef HAVE_OPENSSL_MLDSA44
+	/* Neither a nonempty context nor unencoded ML-DSA is DNSSEC MLDSA44. */
+	int encoding = 0;
+	char context[] = "DNSSEC";
+	OSSL_PARAM contexts[] = {
+		OSSL_PARAM_octet_string(OSSL_SIGNATURE_PARAM_CONTEXT_STRING,
+					context, sizeof(context) - 1),
+		OSSL_PARAM_END,
+	};
+	OSSL_PARAM unencoded[] = {
+		OSSL_PARAM_int(OSSL_SIGNATURE_PARAM_MESSAGE_ENCODING,
+			       &encoding),
+		OSSL_PARAM_END,
+	};
+	const OSSL_PARAM *variants[] = { contexts, unencoded };
+	for (size_t i = 0; i < ARRAY_SIZE(variants); i++) {
+		EVP_MD_CTX *mdctx = EVP_MD_CTX_new();
+		size_t len = DNS_SIG_MLDSA44SIZE;
+		assert_non_null(mdctx);
+		assert_int_equal(
+			EVP_DigestSignInit_ex(mdctx, NULL, NULL, NULL, NULL,
+					      key->keydata.pkeypair.priv,
+					      variants[i]),
+			1);
+		assert_int_equal(EVP_DigestSign(mdctx, signature, &len,
+						data.base, data.length),
+				 1);
+		assert_int_equal(mldsa_verify(pub, &data, &sig),
+				 DST_R_VERIFYFAILURE);
+		EVP_MD_CTX_free(mdctx);
+	}
+#endif
+	dst_key_free(&pub);
+	dst_key_free(&key);
+}
+
+#ifdef HAVE_OPENSSL_MLDSA44
+/*
+ * The private key file holds the seed, so generated and loaded keys must
+ * be writable even when the provider is configured to discard the seed.
+ */
+ISC_RUN_TEST_IMPL(mldsa_noretain) {
+	dns_fixedname_t fname;
+	dns_name_t *name = dns_fixedname_initname(&fname);
+	dst_key_t *key = NULL, *loaded = NULL;
+	EVP_KEYMGMT *keymgmt = NULL;
+	OSSL_PROVIDER *prov = NULL;
+	char keydir[] = BUILDDIR "/mldsa_noretain.XXXXXX";
+	char filename[PATH_MAX] = { 0 };
+	isc_buffer_t buf;
+
+	if (!dst_algorithm_supported(DST_ALG_MLDSA44)) {
+		skip();
+	}
+
+	keymgmt = EVP_KEYMGMT_fetch(NULL, "ML-DSA-44", NULL);
+	assert_non_null(keymgmt);
+	prov = UNCONST(EVP_KEYMGMT_get0_provider(keymgmt));
+	/* OpenSSL 4.0 keeps the seed of generated keys regardless. */
+	assert_int_equal(OSSL_PROVIDER_add_conf_parameter(
+				 prov, "ml-dsa.retain_seed", "no"),
+			 1);
+
+	assert_non_null(mkdtemp(keydir));
+	assert_int_equal(dns_name_fromstring(name, "example.", dns_rootname, 0,
+					     isc_g_mctx),
+			 ISC_R_SUCCESS);
+	assert_int_equal(
+		dst_key_generate(name, DST_ALG_MLDSA44, 0, 0, DNS_KEYOWNER_ZONE,
+				 DNS_KEYPROTO_DNSSEC, dns_rdataclass_in, NULL,
+				 isc_g_mctx, &key, NULL),
+		ISC_R_SUCCESS);
+
+	/* Write a generated key, as dnssec-keygen does. */
+	assert_int_equal(
+		dst_key_tofile(key, DST_TYPE_PUBLIC | DST_TYPE_PRIVATE, keydir),
+		ISC_R_SUCCESS);
+
+	/* Read it back and rewrite it twice, as dnssec-settime does. */
+	for (size_t i = 0; i < 2; i++) {
+		assert_int_equal(
+			dst_key_fromfile(name, dst_key_id(key), DST_ALG_MLDSA44,
+					 DST_TYPE_PUBLIC | DST_TYPE_PRIVATE,
+					 keydir, isc_g_mctx, &loaded),
+			ISC_R_SUCCESS);
+		assert_true(dst_key_compare(key, loaded));
+		assert_int_equal(
+			dst_key_tofile(loaded,
+				       DST_TYPE_PUBLIC | DST_TYPE_PRIVATE,
+				       keydir),
+			ISC_R_SUCCESS);
+		dst_key_free(&loaded);
+	}
+
+	int types[] = { DST_TYPE_PUBLIC, DST_TYPE_PRIVATE };
+	for (size_t i = 0; i < ARRAY_SIZE(types); i++) {
+		isc_buffer_init(&buf, filename, sizeof(filename));
+		assert_int_equal(
+			dst_key_buildfilename(key, types[i], keydir, &buf),
+			ISC_R_SUCCESS);
+		assert_int_equal(remove(filename), 0);
+	}
+	assert_int_equal(rmdir(keydir), 0);
+
+	assert_int_equal(OSSL_PROVIDER_add_conf_parameter(
+				 prov, "ml-dsa.retain_seed", "yes"),
+			 1);
+	dst_key_free(&key);
+	EVP_KEYMGMT_free(keymgmt);
+}
+#endif /* HAVE_OPENSSL_MLDSA44 */
+
 ISC_TEST_LIST_START
 ISC_TEST_ENTRY(algorithm_fromdata)
 ISC_TEST_ENTRY(algorithm_format)
 ISC_TEST_ENTRY(algorithm_totext)
 ISC_TEST_ENTRY(key_format)
 ISC_TEST_ENTRY(sig_test)
+ISC_TEST_ENTRY(mldsa_wire)
+#ifdef HAVE_OPENSSL_MLDSA44
+ISC_TEST_ENTRY(mldsa_noretain)
+#endif /* HAVE_OPENSSL_MLDSA44 */
 ISC_TEST_ENTRY(cmp_test)
 ISC_TEST_ENTRY(ecdsa_determinism_test)
 ISC_TEST_LIST_END
diff --git a/tests/dns/testdata/dst/Kexample.com.+018+59829.key b/tests/dns/testdata/dst/Kexample.com.+018+59829.key
new file mode 100644
index 0000000000..7ae4b038bf
--- /dev/null
+++ b/tests/dns/testdata/dst/Kexample.com.+018+59829.key
@@ -0,0 +1,32 @@
+; draft-westerbaan-dnssec-mldsa-04, Section 6
+example.com. 3600 IN DNSKEY 257 3 18 (
+             17K0clSq4NtF55MNSpjSyX2PE5fReJ2voXAksxbpvslPyZRtQvGbeadBO7qj
+             PnFJy0LtURVpOsBB+suYit61/g4dhjEYSZW1ksOX0ilOLhT5CqQUujgmiZrE
+             P0zMrLwm6agyuVEY1ctDPL75ZgsAE44IF/YediyidMNq1VTrIqrBFi5KsBrL
+             oeOMTv2PgLZbMz0PcuVd/nHOnB67mInnxWEGwP1zgDoq7P6v3teqPLLO2lTR
+             K9jNNqeM+XWUO0er0l6ICsRS5XQu0ejRqCr6huWQx1jBWuTShA2SvKGlCQ9A
+             SWWX/KfYuVE/GhvabpUKqpjeRnUH1KT1pPBZkhZYLDVy9i7aiQWrNYFnDEoC
+             d3oz4Mpylf2PT/bRoKOnaD1l9fX3/GDaAj6CbF+SFEwC99G6EHWYdVPqk2f8
+             122ZC3+pnNRa/biDbUPkWfUYffBYR5cJoB6mg1k1+nBGCZDNPcG6QBupS6sd
+             3kGsZ6szGdysoGBI1MTu8n7hOpwX0FOPQw8tZC3CQVZg3niHfY2KvHJSOXjA
+             QuQoX0MZhGxEEmJCl2hEwQ5Va6IVtacZ5Z0MayqW05hZBx/cws3nUkp77a5U
+             6FsxjoVOj+Ky8+36yXGRKCcKr9HlBEw6T9r9n/MfkHhLjo5FlhRKDa9YZRHT
+             2ZYrnqla8Ze05fxg8rHtFd46W+9fib3HnZEFHZsoFudPpUUx79wcvnTUSIV/
+             R2vNWPIcC2U7O3ak4HamVZowJxhVXMY/dIWaq6uSXwI4YcqM0Pe62yhx9n1V
+             Mm10URNa1F9KG6aRGPuyyKMO7JOS7z+XcGbJrdXHEMxkexUU0hfZWMcBfD6Q
+             /SDATmdLkEhuk3CjGgAdMvRzl55JBnSefkd/oLdFCPil8jeDErg8Jb04jKCw
+             //dHi69CtxZn7arJfEaxKWQ+WG5bBVoMIRlG1PNuZ1vtWGD6BCoxXZgmFk1q
+             kjfDWl+/SVSQpb1N8ki5XEqud4S2BWcxZqxCRbW0sIKgnpMj5i8geMW3Z4NE
+             be/XNq06NwLUmwiYRJAKYYMzl7xEGbMNepegs4fBkRR0xNQbU+Mql3rLbw6n
+             XbZbs55Z5wHnaVfe9vLURVnDGncSK1IE47XCGfFoixTtC8C4AbPm6C3NQ+nA
+             6fQXRM2YFb0byIINi7Ej8E+s0bG2hd1aKxuNu/PtkzZw8JWhgLTxktCLELj6
+             u9/MKyRRjjLuoKXgyQTKhEeACD87DNLQuLavZ7w1W5SUAl3HsKePqA46Lb/r
+             UTKIUdYHgZjpSTZRrnh+wCUfkiujDp9R32Km1yeEzz3SBTkxdt+jJKUSvZSX
+             CjbdNKUUqGeR8Os28BRbCatkZRtKAxOymWEaKhxIiRYnWYdooxFAYLpEQ0ht
+             9RUioc6IswmFwhb45u0XjdVnswSg1Mr7qIKig0LxepqiauWNtjAIPSw1j99W
+             bD9dYqQoVnvJ6ozpXKoPNUdLC/qPM5olCrTfzyCDvo7vvBBV4Y/hU3DuyyYF
+             Ztg/8GshGq7EPKKbVMzQD4gVokZe8LRlFcx+QfMSTwnv/3OTCatYspoUWaAL
+             zlA46TjJZ49y6w5O5f2q5m2fhXP8l/xCtJWfS/i2HXhDPoawM11ukZHE2L9I
+             ezkFwQjP1qwksM633LfPUfhNDtaHuV6uscUzwG8NlwI9kqcIJYN7Wbpst9Tl
+             awqHwgOGKujzFbpZJejt76Z5NpoiAnZhUfFqll+fgeznbMBwtVhp5NuXhM8F
+             yDCzJCyDEg== )
diff --git a/tests/dns/testdata/dst/Kexample.com.+018+59829.private b/tests/dns/testdata/dst/Kexample.com.+018+59829.private
new file mode 100644
index 0000000000..7547d0b49b
--- /dev/null
+++ b/tests/dns/testdata/dst/Kexample.com.+018+59829.private
@@ -0,0 +1,3 @@
+Private-key-format: v1.3
+Algorithm: 18 (MLDSA44)
+PrivateKey: AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=
diff --git a/tests/dns/testdata/dst/mldsa.data b/tests/dns/testdata/dst/mldsa.data
new file mode 100644
index 0000000000..863f0a5343
Binary files /dev/null and b/tests/dns/testdata/dst/mldsa.data differ
diff --git a/tests/dns/testdata/dst/mldsa.md b/tests/dns/testdata/dst/mldsa.md
new file mode 100644
index 0000000000..a7ed00b833
--- /dev/null
+++ b/tests/dns/testdata/dst/mldsa.md
@@ -0,0 +1,10 @@
+The ML-DSA-44 key and signature are the example from Section 6 of
+[draft-westerbaan-dnssec-mldsa-04](https://www.ietf.org/archive/id/draft-westerbaan-dnssec-mldsa-04.html#section-6).
+The private key is the 32-byte seed 00, 01, ..., 1f. The public key's key tag
+is 59829. `mldsa.sig` is the hex encoding of the published signature, and
+`mldsa.data` is the RFC 4034 Section 3.1.8.1 wire encoding of the example's
+RRSIG fields and MX RRset. No signature is regenerated for this fixture.
+
+These four test-vector files are Copyright (c) 2026 IETF Trust and the
+persons identified as the document authors. All rights reserved. They are
+provided under the [Revised BSD License](../../../../LICENSES/BSD-3-Clause.txt).
diff --git a/tests/dns/testdata/dst/mldsa.sig b/tests/dns/testdata/dst/mldsa.sig
new file mode 100644
index 0000000000..8e95e91c87
--- /dev/null
+++ b/tests/dns/testdata/dst/mldsa.sig
@@ -0,0 +1,76 @@
+91DC921F3C01ECD7ED8D048017BB2709E29ABB7368AA92CD835EA1FDE1D957C2
+F76698B7D25911CA24B814B30C66A4E3CDB7F503F6D2860E2A6696259DFA9737
+CBAA15617D1841093A0B5ED4215D178182D39804054F3C2ABC9F9C601CA5729A
+7F0F61B43C59AEA9AA55258DFB582E5BB1F2514168D48B964E764ABA1669B432
+64ABE325FB9647CB8A7ED9377093C307EED2654D893F51568CD702173C1D9880
+9FBE903D33872B39737F2BCED71A3A6317B7A7A3C80118DFB0DCD59FCAB2B450
+656BFE5D9FC24E58AE87E746247A06B939B92B04B4BACF3CE2E916342FF0214F
+D27651A8043557B13D7ADD3AFA9537FCA49E318B170A20DF27B0A485C4FF63F8
+06DA3794A87C802FA782C62052E030AB1157F72A798A3C3DE8150F6121010DBF
+1D54CEDEE3BCBE5A8B2BB9D65552B6FB66982882F612ACE385160A5390E13304
+BD6606C6D278ACCC65E9BD970D70EC80057CAC755B9A0E39D4B9A99854008E52
+B57BF3A2CD9ADF085839D75A83CF304ACF939BCDC87ABAF991E0EFD51E6694B3
+C7401EFF56887E2D9612D6BD3ABE6C188B419FABC55A2F64251BA1BA83085DFF
+422C157F2C72FF3C87A70715D29EBD8EC20E7802E48DDAD9A4DA50F2F017CACF
+3BF14CEA0A5F1E1E768F7A5EFE90580480C4EF499B212F86717748123A1C6DDF
+9F6FA4F2B8092C81E9B2BE29661321E3F49059AA23C6DE759C42058B56E5ECFE
+AC02609D30FF352D2371D3984890A70F78EDF7840D954833BB1B3384B2704ECD
+B8545FD81B16BDDA355FD6ADBB3F7193002F523420A1B6D2392611F67D3DE28D
+D215C2C47268E213C52F69A6F5C886A6E5D2EE837C7FB62E6E5353C1163C6F8A
+6549C5480A9C814F95033A07B347DFFE0E79252F47F320E44C4D19E90F586AE0
+1A9932034CC2F733160DC3AD22D03FF70188A2E7DEDA09AE44AE53D697A1D12A
+5B796145D32F0D4A93B4780C29AB39BDE96669BA443406E2C6207C9F9CE810D9
+CC2D5EB0FF5DDAFB23934F6594BC44A01EA17DD857D031D425DFA9E8C9EC268D
+4159A298AB08657A02479B755B13636EC3B5890659A98B5DC9B7C3677A01857F
+2ECB2FC8AFC9967C3F13B61125690EFBC3EF1C0DADBDF412AFC7CD0B8965FCA1
+DDC2BF1DD10F2CA4F70E1CC32E8B6D170D87A9ACF9A9EB8852B78201BA242EB1
+D9B7490B168D0BDA52C83CB32919776E12FF0350886B305DAA06EA859860266E
+A5FBC6734B206FEF6AC595397D20182B0C792E361A8EBBAF4397E579B02DAD47
+4BB8F6C631BD88ED9F1CE08BC399B03A963A8C2BAD30D5723EC14EE3496CE811
+A888780511D5A4FDDB9285304866146DE44EBE593C8E7BC0CEE9108F6304C13C
+D25BBB0F77F8CFEFA27121D227960D6BFFA4A7B792213F789B84E3EE77A598DD
+169CA160CC664A9EE8831897B0E451677C1D2A7EFA943543EDD6B55A22BEEBCC
+104BE23F16407A8EA35EC0FB4A6722A2937B567C3311673F03F57961DADD3FFB
+25E10F483E4914EA445B1B35DADC314C2E3606B4C27244A6ADF9A4B1FC6625AD
+267DF95A38F1E48DA853A6BAC8B49CC6D5E7C7602BB99CFE6BB1C95917484339
+1D4E66B80AD888671B5568B483C1E3EFFEEF4FAE0F4A9CA98DCBDB2A27C45950
+4BCB0498C7178BDC504F17451B834E93F4D01B9D0E972D6D469F54A0B8F04C3B
+6522387BD4BCFD95A8DB01A6D5E16B48BDDEDCC1A4443B490502DCC881F25154
+C6AC5AF528E9EF77A90053873BE408DB3C9E0B1AD235C92F51EB1113FEAA4894
+2C20CCAF5F45562996988CCB0A447DA60F565A3AEE63992B81D55B20D52A8ECC
+B21AB88F121CB626778D59D39D168030916B466C8A50847B366CB1E91BDC4F3C
+5F8496A2E68CD6BBE81857BF871ABAB1D89FB2B49B834C5600F2C065E8874386
+7BF3E85D0E201EDEC46C781B0903BD57CFBFD7E316C3D66B53A6ED3862762555
+DEC915D8C896AB9FE7273CBD6758E96AEDB303DE66B33933593DCE5410D4AF0C
+BE75866777583EF58B6BBD6EDA88CE39E64B18203C746C55C39A1971CCDDA509
+9EB0EDC22D4463B4C79496AD4BE22D8D640C08387C83FE0B17D4B6516185736D
+4C8A6B305AF821D6307FE21608F42F0F482A40795DBA757A00367B51FE52E6B3
+8DB8F3C755275C323468DA34D5F58AD8AA158B0D527DCBC93DD375552007091F
+CCED48E149E10C86EC1C959BB3B0E5FF9D848B831BA4F2579CBD6030D73A483D
+44913D42798F5F7AC1C5DB4C2BF2D6FB57EA8F3B64C2136EACC81F65678D4E48
+E620A8F60AFCE1F2A0B79D4264CE165B3440C8F61746E4B37A499BF4C30CC12E
+51D86E94F6AD9D904D2177B1A8D2264141728A9709F56E4A9C3E96AF9FA3C942
+F2541411A563F38A97CF14009BE5DE6558FB6BBE1E24BAB62DDEC49D532F9624
+AB986F5AE8C574D926A3F440382754460BE67267C6A4910FD7F1EC73111D7383
+9158A8045992520C0FE570658E7FD8A48806F08C78DA1C8A302B62CAFD60218D
+FF9BC71F1F28F923AC5C0C7513647C8CF7E29265E8A97FAC149E782866E6333F
+D377368CE8F0B23EC37328EACFD58E19AC00B39872EB3BDB4B34310639A6B864
+719614BE0BFD60BF51FB5166ECAC72DDC8E99C6AB25B0C4DE9898DC3F7FA0BED
+B50E57B1EFED3DA36CA08B4335344659D0585D6819B1571B528DB066DEE2AEF6
+D696D333B9DF81D79627C59317FA1A16E8C5B19D9B5C9E9152230CB9C2FA2A1D
+4322F0222D2FBDCE37E5793819106CF03E4FC5FDEF4F7B603F2E6C0D76C9DE54
+F9F0C7A429D4FF1E86EE80339DBB496D19E9C58E728585D26E76F43F1458EA26
+4D476278F1C5D3F74182A54873AFD800ED46A808D151CB2661023A27D8AFDD2E
+5A6975BB3694CCAD174EE966F1F9FA95FA45DC4589612B2AD625D05AE891C3DB
+BA771892D36F9CE19F0DCEE9A8878F16FB3D06172FAA0A5CA1A9D79D03A93759
+92098BDD7D7B96F6E268F9AEACDC82B03A28CAA74C90CA87672EA0A0B57C8F3C
+C3BEF8728D173C3CA0FAFFD10F6113D5FAAE0C61E3795C9506C927359439970B
+CB780CBFB87F90979A84090F5608824FC9D659841F74EA190767EBDC8757C6FA
+F86032DA1786D678517CD0C1B2412FFE30C6D39ABB72205869091089893CADCA
+13EEE985F9F96261928D50A9F28D6DD90C69C09D593C1B93AD45B2D7982BB883
+FC7B8D797F450F5198C51BEA7A26AB14CDA073819BC083D9D8FEF1C40B4B17B8
+2EE59EA983C6BF0B53D24C0FBED7AC171C5CC3F5F7D77FD746C95BC4C7FB2CEA
+138B328DD50F11A9509F03FF7E1F6EB3DD9E49DBF9D2FAF4FCA3E1CDF5B3711C
+16171A1F4B5E739497B7C4D8FE0405111C20292A30648797A6B8BCBEC3DCE7EA
+F9FAFD0B1B5D6A7A85959CA0B3BACAD2DFEBF1FE0E182E373B3F54555B839497
+A3ADB0CED3E8000000000000000000000D233446