Commit 9cde25cc47 for bind

commit 9cde25cc47733a9d62c6f89194776dbed29ca741
Author: OndÅ™ej Surý <ondrej@isc.org>
Date:   Mon Sep 21 15:16:23 2026 +0200

    Dump the dynamic TSIG keys only when the last view releases them

    Saving the dynamic keys was split from releasing the key ring, so
    during a reload the departing view exported (and thereby consumed) the
    contexts that the incoming view was still using, and clients had to
    renegotiate.  Fold the dump into the final detach, so that only the
    last owner writes the key file.

    The dump also ignored per-key failures and reported success whenever
    any generated key existed, so a short write left a truncated file that
    was then renamed over the previously saved sessions.  Propagate write
    errors and discard the temporary file unless it was written and
    renamed in full.

diff --git a/bin/tests/system/tsiggss/tests_gss_context.py b/bin/tests/system/tsiggss/tests_gss_context.py
new file mode 100644
index 0000000000..bcc1451a9b
--- /dev/null
+++ b/bin/tests/system/tsiggss/tests_gss_context.py
@@ -0,0 +1,128 @@
+# 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.
+
+"""
+Exercise GSS-TSIG sessions across view teardown and server restart.
+"""
+
+import os
+import socket
+import struct
+import time
+
+import dns.message
+import dns.name
+import dns.query
+import dns.rcode
+import dns.rdataclass
+import dns.rdatatype
+import dns.rdtypes.ANY.TKEY
+import dns.rrset
+import dns.tsig
+import pytest
+
+import isctest.mark
+
+gssapi = pytest.importorskip("gssapi")
+pytestmark = [
+    isctest.mark.with_gssapi,
+    isctest.mark.with_fips_dh,
+    pytest.mark.extra_artifacts(
+        ["ns1/K*", "ns1/_default.tsigkeys", "ns1/example.nil.db"]
+    ),
+]
+
+
+def read_exact(sock, count):
+    data = bytearray()
+    while len(data) < count:
+        chunk = sock.recv(count - len(data))
+        assert chunk
+        data.extend(chunk)
+    return bytes(data)
+
+
+def establish(ns1, monkeypatch):
+    """
+    Negotiate using the existing ticket cache, without contacting a KDC.
+    """
+    monkeypatch.setenv("KRB5CCNAME", f"FILE:{os.getcwd()}/ns1/administrator.ccache")
+    context = gssapi.SecurityContext(
+        name=gssapi.Name(
+            "DNS/blu.example.nil@EXAMPLE.NIL",
+            name_type=gssapi.NameType.kerberos_principal,
+        ),
+        mech=gssapi.MechType.kerberos,
+        usage="initiate",
+        flags=[
+            gssapi.RequirementFlag.mutual_authentication,
+            gssapi.RequirementFlag.replay_detection,
+            gssapi.RequirementFlag.integrity,
+        ],
+    )
+    token = context.step()
+    name = dns.name.from_text(f"context-sharing-{time.time_ns()}.")
+    now = int(time.time())
+    query = dns.message.make_query(name, "TKEY", "ANY")
+    tkey = dns.rdtypes.ANY.TKEY.TKEY(
+        dns.rdataclass.ANY,
+        dns.rdatatype.TKEY,
+        dns.tsig.GSS_TSIG,
+        now,
+        now + 3600,
+        3,
+        0,
+        token,
+    )
+    query.additional.append(dns.rrset.from_rdata(name, 0, tkey))
+    wire = query.to_wire()
+    with socket.create_connection((ns1.ip, ns1.ports.dns), timeout=5) as sock:
+        sock.sendall(struct.pack("!H", len(wire)) + wire)
+        size = struct.unpack("!H", read_exact(sock, 2))[0]
+        response_wire = read_exact(sock, size)
+    response = dns.message.from_wire(response_wire, keyring=False)
+    assert response.rcode() == dns.rcode.NOERROR
+    assert response.answer[0][0].error == 0
+    context.step(response.answer[0][0].key)
+    assert context.complete
+    key = dns.tsig.Key(name, context, dns.tsig.GSS_TSIG)
+    keyring = {name: key}
+    dns.message.from_wire(response_wire, keyring=keyring)
+    return keyring
+
+
+def signed_query(ns1, keyring):
+    query = dns.message.make_query("example.nil.", "SOA")
+    query.use_tsig(keyring, algorithm=dns.tsig.GSS_TSIG)
+    response = dns.query.tcp(query, ns1.ip, port=ns1.ports.dns, timeout=5)
+    assert response.rcode() == dns.rcode.NOERROR
+    assert response.had_tsig
+
+
+def test_gss_context_survives_reload(ns1, monkeypatch):
+    keyring = establish(ns1, monkeypatch)
+    signed_query(ns1, keyring)
+    for _ in range(3):
+        ns1.reload()
+        # Old views release asynchronous references after reload returns.
+        # Keep querying through that interval with the original session.
+        for _ in range(10):
+            time.sleep(0.1)
+            signed_query(ns1, keyring)
+
+
+def test_gss_context_survives_restart(ns1, monkeypatch):
+    keyring = establish(ns1, monkeypatch)
+    signed_query(ns1, keyring)
+    ns1.stop(["--use-rndc", "--port", str(ns1.ports.rndc)])
+    ns1.start(["--noclean", "--restart", "--port", str(ns1.ports.dns)])
+    # The client keeps its original context: no new TKEY negotiation.
+    signed_query(ns1, keyring)
diff --git a/lib/dns/include/dns/tsig.h b/lib/dns/include/dns/tsig.h
index 66ec1ff0a5..bf59fe3256 100644
--- a/lib/dns/include/dns/tsig.h
+++ b/lib/dns/include/dns/tsig.h
@@ -277,13 +277,34 @@ dns_tsigkeyring_add(dns_tsigkeyring_t *ring, dns_tsigkey_t *tkey);
  *\li		Any other value indicates failure.
  */

+#if DNS_TSIG_TRACE
+#define dns_tsigkeyring_dumpanddetach(ringp, keyfile)                      \
+	dns_tsigkeyring__dumpanddetach(ringp, keyfile, __func__, __FILE__, \
+				       __LINE__)
+isc_result_t
+dns_tsigkeyring__dumpanddetach(dns_tsigkeyring_t **ringp, const char *keyfile,
+			       const char *func, const char *file,
+			       const unsigned int line);
+#else
 isc_result_t
-dns_tsigkeyring_dump(dns_tsigkeyring_t *ring, FILE *fp);
+dns_tsigkeyring_dumpanddetach(dns_tsigkeyring_t **ringp, const char *keyfile);
+#endif
 /*%<
- *	Dump a TSIG key ring to 'fp'.
+ *	Dump a TSIG key ring to file named 'keyfile'.
+ *
+ *	The caller's reference is always released and '*ringp' is set to
+ *	NULL, whatever the result.  Only the final owner writes 'keyfile';
+ *	any other caller gets #DNS_R_CONTINUE and writes nothing, because
+ *	dumping a GSS key consumes its security context.
  *
  *	Requires:
- *\li		'ring' is a valid keyring.
+ *\li		'*ringp' is a valid keyring.
+ *
+ *	Returns:
+ *\li		#ISC_R_SUCCESS if at least one key was written.
+ *\li		#ISC_R_NOTFOUND if no keys could be written.
+ *\li		#ISC_R_IOERROR on a write error.
+ *\li		#DNS_R_CONTINUE if we continue to use the keyring.
  */

 void
diff --git a/lib/dns/tsig.c b/lib/dns/tsig.c
index c1d000eed5..7f785200f0 100644
--- a/lib/dns/tsig.c
+++ b/lib/dns/tsig.c
@@ -18,6 +18,7 @@
 #include <stdlib.h>

 #include <isc/buffer.h>
+#include <isc/file.h>
 #include <isc/hashmap.h>
 #include <isc/log.h>
 #include <isc/mem.h>
@@ -243,7 +244,7 @@ dns__tsigkey_deletelru(dns_tsigkeyring_t *ring, dns_tsigkey_t *tkey) {
 }

 static void
-destroyring(dns_tsigkeyring_t *ring) {
+dns_tsigkeyring__destroy(dns_tsigkeyring_t *ring) {
 	isc_result_t result;
 	isc_hashmap_iter_t *it = NULL;

@@ -269,9 +270,9 @@ destroyring(dns_tsigkeyring_t *ring) {
 }

 #if DNS_TSIG_TRACE
-ISC_REFCOUNT_TRACE_IMPL(dns_tsigkeyring, destroyring);
+ISC_REFCOUNT_TRACE_IMPL(dns_tsigkeyring, dns_tsigkeyring__destroy);
 #else
-ISC_REFCOUNT_IMPL(dns_tsigkeyring, destroyring);
+ISC_REFCOUNT_IMPL(dns_tsigkeyring, dns_tsigkeyring__destroy);
 #endif

 /*
@@ -353,43 +354,47 @@ restore_key(dns_tsigkeyring_t *ring, isc_stdtime_t now, FILE *fp) {
 	return result;
 }

-static void
+static isc_result_t
 dump_key(dns_tsigkey_t *tkey, FILE *fp) {
 	char *buffer = NULL;
 	int length = 0;
 	char namestr[DNS_NAME_FORMATSIZE];
 	char creatorstr[DNS_NAME_FORMATSIZE];
 	char algorithmstr[DNS_NAME_FORMATSIZE];
-	isc_result_t result;

 	REQUIRE(tkey != NULL);
+	REQUIRE(tkey->key != NULL);
+	REQUIRE(tkey->creator != NULL);
 	REQUIRE(fp != NULL);

 	dns_name_format(tkey->name, namestr, sizeof(namestr));
 	dns_name_format(tkey->creator, creatorstr, sizeof(creatorstr));
 	dns_name_format(dns_tsigkey_algorithm(tkey), algorithmstr,
 			sizeof(algorithmstr));
-	result = dst_key_dump(tkey->key, tkey->mctx, &buffer, &length);
-	if (result == ISC_R_SUCCESS) {
-		fprintf(fp, "%s %s %u %u %s %.*s\n", namestr, creatorstr,
+	RETERR(dst_key_dump(tkey->key, tkey->mctx, &buffer, &length));
+
+	int n = fprintf(fp, "%s %s %u %u %s %.*s\n", namestr, creatorstr,
 			tkey->inception, tkey->expire, algorithmstr, length,
 			buffer);
+	isc_mem_put(tkey->mctx, buffer, length);
+
+	if (n < 0) {
+		return ISC_R_IOERROR;
 	}
-	if (buffer != NULL) {
-		isc_mem_put(tkey->mctx, buffer, length);
-	}
+
+	return ISC_R_SUCCESS;
 }

-isc_result_t
-dns_tsigkeyring_dump(dns_tsigkeyring_t *ring, FILE *fp) {
+static isc_result_t
+dns_tsigkeyring__dumptofile(dns_tsigkeyring_t *ring, FILE *fp) {
+	REQUIRE(VALID_TSIGKEYRING(ring));
+	REQUIRE(fp != NULL);
+
 	isc_result_t result;
 	isc_stdtime_t now = isc_stdtime_now();
 	isc_hashmap_iter_t *it = NULL;
 	bool found = false;

-	REQUIRE(VALID_TSIGKEYRING(ring));
-
-	RWLOCK(&ring->lock, isc_rwlocktype_read);
 	isc_hashmap_iter_create(ring->keys, &it);
 	for (result = isc_hashmap_iter_first(it); result == ISC_R_SUCCESS;
 	     result = isc_hashmap_iter_next(it))
@@ -398,14 +403,73 @@ dns_tsigkeyring_dump(dns_tsigkeyring_t *ring, FILE *fp) {
 		isc_hashmap_iter_current(it, (void **)&tkey);

 		if (tkey->generated && tkey->expire >= now) {
-			dump_key(tkey, fp);
+			result = dump_key(tkey, fp);
+			if (result != ISC_R_SUCCESS) {
+				tsig_log(tkey, ISC_LOG_WARNING,
+					 "could not dump key: %s",
+					 isc_result_totext(result));
+				if (result == ISC_R_IOERROR) {
+					break;
+				}
+				continue;
+			}
 			found = true;
+			if (ferror(fp)) {
+				result = ISC_R_IOERROR;
+				break;
+			}
 		}
 	}
+	if (result == ISC_R_NOMORE) {
+		result = found ? ISC_R_SUCCESS : ISC_R_NOTFOUND;
+	}
 	isc_hashmap_iter_destroy(&it);
+
+	return result;
+}
+
+static isc_result_t
+dns_tsigkeyring__dump(dns_tsigkeyring_t *ring, const char *keyfile) {
+	REQUIRE(VALID_TSIGKEYRING(ring));
+	REQUIRE(keyfile != NULL);
+
+	FILE *fp = NULL;
+	char template[PATH_MAX];
+	bool created = false;
+	isc_result_t result;
+
+	RWLOCK(&ring->lock, isc_rwlocktype_read);
+
+	if (isc_hashmap_count(ring->keys) == 0) {
+		CLEANUP(ISC_R_NOTFOUND);
+	}
+
+	CHECK(isc_file_mktemplate(keyfile, template, sizeof(template)));
+	CHECK(isc_file_openuniqueprivate(template, &fp));
+	created = true;
+
+	result = dns_tsigkeyring__dumptofile(ring, fp);
+
+	if (fclose(fp) != 0 && result == ISC_R_SUCCESS) {
+		result = ISC_R_IOERROR;
+	}
+	fp = NULL;
+	CHECK(result);
+
+	CHECK(isc_file_rename(template, keyfile));
+	created = false;
+
+cleanup:
+	if (fp != NULL) {
+		(void)fclose(fp);
+	}
+	if (created) {
+		(void)isc_file_remove(template);
+	}
+
 	RWUNLOCK(&ring->lock, isc_rwlocktype_read);

-	return found ? ISC_R_SUCCESS : ISC_R_NOTFOUND;
+	return result;
 }

 const dns_name_t *
@@ -422,6 +486,38 @@ dns_tsigkey_identity(const dns_tsigkey_t *tsigkey) {
 	}
 }

+#if DNS_TSIG_TRACE
+isc_result_t
+dns_tsigkeyring__dumpanddetach(dns_tsigkeyring_t **ringp, const char *keyfile,
+			       const char *func, const char *file,
+			       const unsigned int line) {
+#else
+isc_result_t
+dns_tsigkeyring_dumpanddetach(dns_tsigkeyring_t **ringp, const char *keyfile) {
+#endif
+	REQUIRE(ringp != NULL && VALID_TSIGKEYRING(*ringp));
+	REQUIRE(keyfile != NULL);
+
+	dns_tsigkeyring_t *ring = MOVE_OWNERSHIP(*ringp);
+	isc_result_t result = DNS_R_CONTINUE;
+	uint_fast32_t refs = isc_refcount_decrement(&ring->references) - 1;
+
+	if (refs == 0) {
+		isc_refcount_destroy(&ring->references);
+
+		result = dns_tsigkeyring__dump(ring, keyfile);
+		dns_tsigkeyring__destroy(ring);
+	}
+
+#if DNS_TSIG_TRACE
+	fprintf(stderr,
+		"%s:%s:%s:%u:t%" PRItid ":%p->references = %" PRIuFAST32 "\n",
+		__func__, func, file, line, isc_tid(), ring, refs);
+#endif
+
+	return result;
+}
+
 isc_result_t
 dns_tsigkey_create(const dns_name_t *name, dst_algorithm_t algorithm,
 		   unsigned char *secret, int length, isc_mem_t *mctx,
diff --git a/lib/dns/view.c b/lib/dns/view.c
index 74a256ef98..c82d3358be 100644
--- a/lib/dns/view.c
+++ b/lib/dns/view.c
@@ -179,6 +179,34 @@ dns_view_create(isc_mem_t *mctx, dns_dispatchmgr_t *dispatchmgr,
 	*viewp = view;
 }

+static void
+dumpanddetach_tsigkeys(dns_view_t *view) {
+	char keyfile[PATH_MAX];
+	isc_result_t result;
+
+	REQUIRE(view->dynamickeys != NULL);
+
+	result = isc_file_sanitize(NULL, view->name, "tsigkeys", keyfile,
+				   sizeof(keyfile));
+	if (result != ISC_R_SUCCESS) {
+		dns_tsigkeyring_detach(&view->dynamickeys);
+	} else {
+		result = dns_tsigkeyring_dumpanddetach(&view->dynamickeys,
+						       keyfile);
+	}
+
+	switch (result) {
+	case ISC_R_SUCCESS:
+	case DNS_R_CONTINUE:
+	case ISC_R_NOTFOUND:
+		break;
+	default:
+		isc_log_write(DNS_LOGCATEGORY_DNSSEC, DNS_LOGMODULE_TSIG,
+			      ISC_LOG_INFO, "failed to dump TSIG keys: %s",
+			      isc_result_totext(result));
+	}
+}
+
 static void
 destroy(dns_view_t *view) {
 	dns_dns64_t *dns64 = NULL;
@@ -200,36 +228,7 @@ destroy(dns_view_t *view) {
 	}

 	if (view->dynamickeys != NULL) {
-		isc_result_t result;
-		char template[PATH_MAX];
-		char keyfile[PATH_MAX];
-		FILE *fp = NULL;
-
-		result = isc_file_mktemplate(NULL, template, sizeof(template));
-		if (result == ISC_R_SUCCESS) {
-			(void)isc_file_openuniqueprivate(template, &fp);
-		}
-		if (fp != NULL) {
-			result = dns_tsigkeyring_dump(view->dynamickeys, fp);
-			if (result == ISC_R_SUCCESS) {
-				if (fclose(fp) == 0) {
-					result = isc_file_sanitize(
-						NULL, view->name, "tsigkeys",
-						keyfile, sizeof(keyfile));
-					if (result == ISC_R_SUCCESS) {
-						result = isc_file_rename(
-							template, keyfile);
-					}
-				}
-				if (result != ISC_R_SUCCESS) {
-					(void)remove(template);
-				}
-			} else {
-				(void)fclose(fp);
-				(void)remove(template);
-			}
-		}
-		dns_tsigkeyring_detach(&view->dynamickeys);
+		dumpanddetach_tsigkeys(view);
 	}
 	if (view->transports != NULL) {
 		dns_transport_list_detach(&view->transports);
diff --git a/tests/dns/tsig_test.c b/tests/dns/tsig_test.c
index 39336ffcf3..d162ccce18 100644
--- a/tests/dns/tsig_test.c
+++ b/tests/dns/tsig_test.c
@@ -20,13 +20,19 @@
 #include <stdlib.h>
 #include <unistd.h>

+/* Include OpenSSL before cmocka redefines the allocator names. */
+#include <openssl/err.h>
+
 #define UNIT_TESTING
 #include <cmocka.h>

+#include <isc/atomic.h>
 #include <isc/lib.h>
 #include <isc/mem.h>
 #include <isc/random.h>
 #include <isc/result.h>
+#include <isc/stdtime.h>
+#include <isc/thread.h>
 #include <isc/util.h>

 #include <dns/lib.h>
@@ -34,6 +40,7 @@
 #include <dns/rdataset.h>
 #include <dns/tsig.h>

+#include "dst_internal.h"
 #include "tsig_p.h"

 #include <tests/dns.h>
@@ -561,6 +568,327 @@ ISC_RUN_TEST_IMPL(tsig_maxkeys) {
 	dns_tsigkeyring_detach(&ring);
 }

+/*
+ * dns_tsigkeyring_dump() can only write a key whose DST provider
+ * implements dump(), which in practice means GSS-TSIG.  Stand in for the
+ * provider with a copy of the HMAC function table so that the dump paths
+ * can be exercised without a Kerberos session.
+ */
+#define MOCK_KEYDATA   "dGVzdA=="
+#define TEST_INCEPTION 4242
+#define TEST_CREATOR   "creator.example"
+
+static dst_func_t dump_funcs;
+static isc_result_t dump_result;
+
+static isc_result_t
+mock_dump(dst_key_t *key, isc_mem_t *mctx, char **buffer, int *length) {
+	UNUSED(key);
+
+	if (dump_result != ISC_R_SUCCESS) {
+		return dump_result;
+	}
+
+	*length = sizeof(MOCK_KEYDATA) - 1;
+	*buffer = isc_mem_get(mctx, *length);
+	memmove(*buffer, MOCK_KEYDATA, *length);
+
+	return ISC_R_SUCCESS;
+}
+
+/*
+ * Add a key to 'ring'.  Only a generated, unexpired key with a working
+ * provider dump() is eligible to be written out.
+ */
+static void
+add_key(dns_tsigkeyring_t *ring, const char *namestr, bool generated,
+	isc_stdtime_t expire, bool dumpable) {
+	unsigned char secret[] = "a test secret";
+	dns_fixedname_t fname, fcreator;
+	dns_name_t *name = dns_fixedname_initname(&fname);
+	dns_name_t *creator = dns_fixedname_initname(&fcreator);
+	dns_tsigkey_t *tkey = NULL, *tmp = NULL;
+	isc_result_t result;
+
+	result = dns_name_fromstring(name, namestr, dns_rootname, 0, NULL);
+	assert_int_equal(result, ISC_R_SUCCESS);
+	result = dns_name_fromstring(creator, TEST_CREATOR, dns_rootname, 0,
+				     NULL);
+	assert_int_equal(result, ISC_R_SUCCESS);
+
+	/* dns_tsigkey_create() derives the DST key from the secret. */
+	result = dns_tsigkey_create(name, DST_ALG_HMACSHA256, secret,
+				    sizeof(secret), isc_g_mctx, &tmp);
+	assert_int_equal(result, ISC_R_SUCCESS);
+
+	if (dumpable) {
+		dump_funcs = *tmp->key->func;
+		dump_funcs.dump = mock_dump;
+		tmp->key->func = &dump_funcs;
+	}
+
+	result = dns_tsigkey_createfromkey(
+		name, DST_ALG_HMACSHA256, tmp->key, generated, false, creator,
+		TEST_INCEPTION, expire, isc_g_mctx, &tkey);
+	assert_int_equal(result, ISC_R_SUCCESS);
+	dns_tsigkey_detach(&tmp);
+
+	result = dns_tsigkeyring_add(ring, tkey);
+	assert_int_equal(result, ISC_R_SUCCESS);
+	dns_tsigkey_detach(&tkey);
+}
+
+#define TEST_KEYFILE BUILDDIR "/tsigkeys.test"
+
+static void
+remove_keyfile(void) {
+	(void)unlink(TEST_KEYFILE);
+}
+
+static bool
+keyfile_exists(void) {
+	return access(TEST_KEYFILE, F_OK) == 0;
+}
+
+/*
+ * Parse the dumped key file, returning the number of keys it holds.  The
+ * out parameters describe the last key read.
+ */
+static unsigned int
+read_keyfile(char *namestr, char *creatorstr, char *algstr, char *keystr,
+	     isc_stdtime_t *inception, isc_stdtime_t *expire) {
+	char line[4096] = { 0 };
+	unsigned int keys = 0;
+	FILE *fp = fopen(TEST_KEYFILE, "r");
+	assert_non_null(fp);
+
+	while (fgets(line, sizeof(line), fp) != NULL) {
+		/* Each field buffer holds DNS_NAME_FORMATSIZE (1024). */
+		assert_int_equal(sscanf(line,
+					"%1023s %1023s %u %u %1023s %1023s",
+					namestr, creatorstr, inception, expire,
+					algstr, keystr),
+				 6);
+		keys++;
+	}
+	int ret = fclose(fp);
+	assert_int_equal(ret, 0);
+
+	return keys;
+}
+
+/*
+ * A reload shares the dynamic keyring between the old and the new view.
+ * Exporting a GSS context consumes it, so only the final owner may dump.
+ */
+ISC_RUN_TEST_IMPL(tsig_dumpanddetach_shared) {
+	char namestr[DNS_NAME_FORMATSIZE], creatorstr[DNS_NAME_FORMATSIZE];
+	char algstr[DNS_NAME_FORMATSIZE], keystr[4096];
+	isc_stdtime_t inception, expire, now = isc_stdtime_now();
+	dns_fixedname_t fname;
+	dns_name_t *name = dns_fixedname_initname(&fname);
+	dns_tsigkeyring_t *ring = NULL, *shared = NULL;
+	dns_tsigkey_t *found = NULL;
+	isc_result_t result;
+
+	remove_keyfile();
+	dump_result = ISC_R_SUCCESS;
+	dns_tsigkeyring_create(isc_g_mctx, &ring);
+	add_key(ring, "session.example", true, now + 3600, true);
+	dns_tsigkeyring_attach(ring, &shared);
+
+	result = dns_tsigkeyring_dumpanddetach(&ring, TEST_KEYFILE);
+	assert_int_equal(result, DNS_R_CONTINUE);
+	assert_null(ring);
+	assert_false(keyfile_exists());
+
+	/* The surviving owner still resolves the key. */
+	result = dns_name_fromstring(name, "session.example", dns_rootname, 0,
+				     NULL);
+	assert_int_equal(result, ISC_R_SUCCESS);
+	result = dns_tsigkey_find(&found, name, NULL, shared);
+	assert_int_equal(result, ISC_R_SUCCESS);
+	dns_tsigkey_detach(&found);
+
+	result = dns_tsigkeyring_dumpanddetach(&shared, TEST_KEYFILE);
+	assert_int_equal(result, ISC_R_SUCCESS);
+	assert_null(shared);
+	assert_int_equal(read_keyfile(namestr, creatorstr, algstr, keystr,
+				      &inception, &expire),
+			 1);
+	assert_string_equal(namestr, "session.example");
+	remove_keyfile();
+}
+
+ISC_RUN_TEST_IMPL(tsig_dumpanddetach_nothing) {
+	isc_stdtime_t now = isc_stdtime_now();
+	dns_tsigkeyring_t *ring = NULL;
+	isc_result_t result;
+
+	remove_keyfile();
+	dump_result = ISC_R_SUCCESS;
+
+	/* An empty ring. */
+	dns_tsigkeyring_create(isc_g_mctx, &ring);
+	result = dns_tsigkeyring_dumpanddetach(&ring, TEST_KEYFILE);
+	assert_int_equal(result, ISC_R_NOTFOUND);
+	assert_null(ring);
+
+	/* A statically configured key is not written out. */
+	dns_tsigkeyring_create(isc_g_mctx, &ring);
+	add_key(ring, "static.example", false, now + 3600, true);
+	result = dns_tsigkeyring_dumpanddetach(&ring, TEST_KEYFILE);
+	assert_int_equal(result, ISC_R_NOTFOUND);
+
+	/* An expired generated key is not written out. */
+	dns_tsigkeyring_create(isc_g_mctx, &ring);
+	add_key(ring, "expired.example", true, now - 1, true);
+	result = dns_tsigkeyring_dumpanddetach(&ring, TEST_KEYFILE);
+	assert_int_equal(result, ISC_R_NOTFOUND);
+
+	/* A provider without dump() support, i.e. every non-GSS key. */
+	dns_tsigkeyring_create(isc_g_mctx, &ring);
+	add_key(ring, "hmac.example", true, now + 3600, false);
+	result = dns_tsigkeyring_dumpanddetach(&ring, TEST_KEYFILE);
+	assert_int_equal(result, ISC_R_NOTFOUND);
+
+	/* A provider whose dump() fails. */
+	dns_tsigkeyring_create(isc_g_mctx, &ring);
+	add_key(ring, "broken.example", true, now + 3600, true);
+	dump_result = ISC_R_FAILURE;
+	result = dns_tsigkeyring_dumpanddetach(&ring, TEST_KEYFILE);
+	assert_int_equal(result, ISC_R_NOTFOUND);
+
+	/* Nothing above may have published a key file. */
+	assert_false(keyfile_exists());
+}
+
+ISC_RUN_TEST_IMPL(tsig_dumpanddetach_key) {
+	char namestr[DNS_NAME_FORMATSIZE], creatorstr[DNS_NAME_FORMATSIZE];
+	char algstr[DNS_NAME_FORMATSIZE], keystr[4096];
+	isc_stdtime_t inception, expire, now = isc_stdtime_now();
+	dns_tsigkeyring_t *ring = NULL;
+	isc_result_t result;
+
+	remove_keyfile();
+	dump_result = ISC_R_SUCCESS;
+	dns_tsigkeyring_create(isc_g_mctx, &ring);
+	add_key(ring, "session.example", true, now + 3600, true);
+
+	result = dns_tsigkeyring_dumpanddetach(&ring, TEST_KEYFILE);
+	assert_int_equal(result, ISC_R_SUCCESS);
+	assert_null(ring);
+
+	assert_int_equal(read_keyfile(namestr, creatorstr, algstr, keystr,
+				      &inception, &expire),
+			 1);
+	assert_string_equal(namestr, "session.example");
+	assert_string_equal(creatorstr, TEST_CREATOR);
+	assert_int_equal(inception, TEST_INCEPTION);
+	assert_int_equal(expire, now + 3600);
+	assert_string_equal(algstr, "hmac-sha256");
+	assert_string_equal(keystr, MOCK_KEYDATA);
+	remove_keyfile();
+}
+
+/* An undumpable key must not suppress the keys that can be dumped. */
+ISC_RUN_TEST_IMPL(tsig_dumpanddetach_skips_undumpable) {
+	char namestr[DNS_NAME_FORMATSIZE], creatorstr[DNS_NAME_FORMATSIZE];
+	char algstr[DNS_NAME_FORMATSIZE], keystr[4096];
+	isc_stdtime_t inception, expire, now = isc_stdtime_now();
+	dns_tsigkeyring_t *ring = NULL;
+	isc_result_t result;
+
+	remove_keyfile();
+	dump_result = ISC_R_SUCCESS;
+	dns_tsigkeyring_create(isc_g_mctx, &ring);
+	add_key(ring, "hmac.example", true, now + 3600, false);
+	add_key(ring, "session.example", true, now + 3600, true);
+
+	result = dns_tsigkeyring_dumpanddetach(&ring, TEST_KEYFILE);
+	assert_int_equal(result, ISC_R_SUCCESS);
+	assert_int_equal(read_keyfile(namestr, creatorstr, algstr, keystr,
+				      &inception, &expire),
+			 1);
+	assert_string_equal(namestr, "session.example");
+	remove_keyfile();
+}
+
+/* A key file that cannot be published must not be left half written. */
+ISC_RUN_TEST_IMPL(tsig_dumpanddetach_unwritable) {
+	isc_stdtime_t now = isc_stdtime_now();
+	dns_tsigkeyring_t *ring = NULL;
+	isc_result_t result;
+
+	remove_keyfile();
+	dump_result = ISC_R_SUCCESS;
+	dns_tsigkeyring_create(isc_g_mctx, &ring);
+	add_key(ring, "session.example", true, now + 3600, true);
+
+	result = dns_tsigkeyring_dumpanddetach(&ring,
+					       TEST_KEYFILE "/nonexistent/x");
+	assert_int_not_equal(result, ISC_R_SUCCESS);
+	assert_int_not_equal(result, DNS_R_CONTINUE);
+	assert_null(ring);
+	assert_false(keyfile_exists());
+}
+
+typedef struct {
+	dns_tsigkeyring_t *ring;
+	atomic_bool *start;
+	isc_result_t result;
+} dump_thread_t;
+
+static void *
+dump_thread(void *arg) {
+	dump_thread_t *ctx = arg;
+
+	while (!atomic_load_acquire(ctx->start)) {
+		isc_thread_yield();
+	}
+	ctx->result = dns_tsigkeyring_dumpanddetach(&ctx->ring, TEST_KEYFILE);
+
+	return NULL;
+}
+
+/* Exactly one owner may dump, however the detaches interleave. */
+ISC_RUN_TEST_IMPL(tsig_dumpanddetach_concurrent) {
+	isc_stdtime_t now = isc_stdtime_now();
+	dns_tsigkeyring_t *ring = NULL;
+	isc_thread_t threads[8];
+	dump_thread_t contexts[8] = { 0 };
+	atomic_bool start = false;
+	unsigned int dumped = 0, continued = 0;
+
+	remove_keyfile();
+	dump_result = ISC_R_SUCCESS;
+	dns_tsigkeyring_create(isc_g_mctx, &ring);
+	add_key(ring, "session.example", true, now + 3600, true);
+
+	for (size_t i = 0; i < ARRAY_SIZE(threads); i++) {
+		dns_tsigkeyring_attach(ring, &contexts[i].ring);
+		contexts[i].start = &start;
+		isc_thread_create(dump_thread, &contexts[i], &threads[i]);
+	}
+	dns_tsigkeyring_detach(&ring);
+	atomic_store_release(&start, true);
+
+	for (size_t i = 0; i < ARRAY_SIZE(threads); i++) {
+		isc_thread_join(threads[i], NULL);
+		assert_null(contexts[i].ring);
+		if (contexts[i].result == ISC_R_SUCCESS) {
+			dumped++;
+		} else {
+			assert_int_equal(contexts[i].result, DNS_R_CONTINUE);
+			continued++;
+		}
+	}
+	assert_int_equal(dumped, 1);
+	assert_int_equal(continued, ARRAY_SIZE(threads) - 1);
+	assert_true(keyfile_exists());
+	remove_keyfile();
+}
+
 /* Tests the dns__tsig_algvalid function */
 ISC_RUN_TEST_IMPL(algvalid) {
 	UNUSED(state);
@@ -583,6 +911,12 @@ ISC_TEST_ENTRY(tsig_badtime)
 ISC_TEST_ENTRY(tsig_delete)
 ISC_TEST_ENTRY(tsig_tcp)
 ISC_TEST_ENTRY(tsig_maxkeys)
+ISC_TEST_ENTRY(tsig_dumpanddetach_shared)
+ISC_TEST_ENTRY(tsig_dumpanddetach_nothing)
+ISC_TEST_ENTRY(tsig_dumpanddetach_key)
+ISC_TEST_ENTRY(tsig_dumpanddetach_skips_undumpable)
+ISC_TEST_ENTRY(tsig_dumpanddetach_unwritable)
+ISC_TEST_ENTRY(tsig_dumpanddetach_concurrent)
 ISC_TEST_LIST_END

 ISC_TEST_MAIN