]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Convert model2.multisigner test to pytest
authorMatthijs Mekking <matthijs@isc.org>
Fri, 10 Oct 2025 08:57:50 +0000 (10:57 +0200)
committerMatthijs Mekking <matthijs@isc.org>
Fri, 28 Nov 2025 14:30:31 +0000 (14:30 +0000)
This converts the model2.multisigner tests from the multisigner system
test to pytest based code. Crappy shell test functions such as
'zsks_are_published', 'records_published' and others are replaced with
the standard test code from isctest.kasp and by setting 'private=False'
and 'legacy=True' on the keys from the other providers so we don't do
any key file testing.

bin/tests/system/multisigner/ns3/setup.sh
bin/tests/system/multisigner/ns4/setup.sh
bin/tests/system/multisigner/tests.sh
bin/tests/system/multisigner/tests_multisigner.py [new file with mode: 0644]

index 123528f35c88f1cb329f8deb11105bdd7cf4dc8a..80327a1300c262fc78966e09dc9653b52a2ffd91 100644 (file)
@@ -28,8 +28,6 @@ KSK=$($KEYGEN -a $DEFAULT_ALGORITHM -f KSK -L 3600 $ksktimes $zone 2>keygen.out.
 ZSK=$($KEYGEN -a $DEFAULT_ALGORITHM -L 3600 $zsktimes $zone 2>keygen.out.$zone.2)
 $SETTIME -s -g $O -k $O now -r $O now -d $O now "$KSK" >settime.out.$zone.1 2>&1
 $SETTIME -s -g $O -k $O now -z $O now "$ZSK" >settime.out.$zone.2 2>&1
-# ZSK will be added to the other provider with nsupdate.
-cat "${ZSK}.key" | grep -v ";.*" >"${zone}.zsk"
 
 zone="model2.secondary"
 echo_i "setting up zone: $zone"
index dc3fc7cebd1b261cbd8a39094ed171ee9bb8c6c7..c58ddce4954bb63aa512850a86fad281332e2a17 100644 (file)
@@ -28,8 +28,6 @@ KSK=$($KEYGEN -a $DEFAULT_ALGORITHM -f KSK -L 3600 $ksktimes $zone 2>keygen.out.
 ZSK=$($KEYGEN -a $DEFAULT_ALGORITHM -L 3600 $zsktimes $zone 2>keygen.out.$zone.2)
 $SETTIME -s -g $O -k $O now -r $O now -d $O now "$KSK" >settime.out.$zone.1 2>&1
 $SETTIME -s -g $O -k $O now -z $O now "$ZSK" >settime.out.$zone.2 2>&1
-# ZSK will be added to the other provider with nsupdate.
-cat "${ZSK}.key" | grep -v ";.*" >"${zone}.zsk"
 
 zone="model2.secondary"
 echo_i "setting up zone: $zone"
index abe19ff21548432fc5294e2a3bbf146728994634..c0141e8d8533c29d7d08450776861d94250bd605 100644 (file)
@@ -26,82 +26,6 @@ start_time="$(TZ=UTC date +%s)"
 status=0
 n=0
 
-set_zone "model2.multisigner"
-set_policy "model2" "2" "3600"
-
-# Key properties and states.
-key_clear "KEY1"
-set_keyrole "KEY1" "ksk"
-set_keylifetime "KEY1" "0"
-set_keyalgorithm "KEY1" "13" "ECDSAP256SHA256" "256"
-set_keysigning "KEY1" "yes"
-set_zonesigning "KEY1" "no"
-set_keystate "KEY1" "GOAL" "omnipresent"
-set_keystate "KEY1" "STATE_DNSKEY" "omnipresent"
-set_keystate "KEY1" "STATE_KRRSIG" "omnipresent"
-set_keystate "KEY1" "STATE_DS" "omnipresent"
-
-key_clear "KEY2"
-set_keyrole "KEY2" "zsk"
-set_keylifetime "KEY2" "0"
-set_keyalgorithm "KEY2" "13" "ECDSAP256SHA256" "256"
-set_keysigning "KEY2" "no"
-set_zonesigning "KEY2" "yes"
-set_keystate "KEY2" "GOAL" "omnipresent"
-set_keystate "KEY2" "STATE_DNSKEY" "omnipresent"
-set_keystate "KEY2" "STATE_ZRRSIG" "omnipresent"
-
-key_clear "KEY3"
-key_clear "KEY4"
-
-set_keytimes_model2() {
-  # The first KSK is immediately published and activated.
-  created=$(key_get KEY1 CREATED)
-  set_keytime "KEY1" "PUBLISHED" "${created}"
-  set_keytime "KEY1" "ACTIVE" "${created}"
-  set_keytime "KEY1" "SYNCPUBLISH" "${created}"
-
-  # The first ZSKs are immediately published and activated.
-  created=$(key_get KEY2 CREATED)
-  set_keytime "KEY2" "PUBLISHED" "${created}"
-  set_keytime "KEY2" "ACTIVE" "${created}"
-}
-
-set_server "ns3" "10.53.0.3"
-check_keys
-check_dnssecstatus "$SERVER" "$POLICY" "$ZONE"
-set_keytimes_model2
-check_keytimes
-check_apex
-dnssec_verify
-
-set_server "ns4" "10.53.0.4"
-check_keys
-check_dnssecstatus "$SERVER" "$POLICY" "$ZONE"
-set_keytimes_model2
-check_keytimes
-check_apex
-dnssec_verify
-
-#
-# Update DNSKEY RRset.
-#
-
-# Check that the ZSKs from the other provider are published.
-zsks_are_published() {
-  dig_with_opts "$ZONE" "@${SERVER}" DNSKEY >"dig.out.$DIR.test$n" || return 1
-  cat dig.out.$DIR.test$n | tr [:blank:] ' ' >dig.out.$DIR.test$n.tr || return 1
-  # We should have two ZSKs.
-  lines=$(grep "256 3 13" dig.out.$DIR.test$n.tr | wc -l)
-  test "$lines" -eq 2 || return 1
-  # Both ZSKs are published.
-  grep "$(cat ns3/${ZONE}.zsk | tr [:blank:] ' ')" dig.out.$DIR.test$n.tr >/dev/null || return 1
-  grep "$(cat ns4/${ZONE}.zsk | tr [:blank:] ' ')" dig.out.$DIR.test$n.tr >/dev/null || return 1
-  # And one KSK.
-  lines=$(grep "257 3 13" dig.out.$DIR.test$n.tr | wc -l)
-  test "$lines" -eq 1 || return 1
-}
-
 # Test to make sure no DNSSEC records end up in the raw journal.
 no_dnssec_in_journal() {
   n=$((n + 1))
@@ -124,150 +48,6 @@ rrset_exists() (
   test "$lines" -gt 0
 )
 
-n=$((n + 1))
-echo_i "add dnskey record: update zone ${ZONE} at ns3 with ZSK from provider ns4 ($n)"
-ret=0
-set_server "ns3" "10.53.0.3"
-(
-  echo zone "${ZONE}"
-  echo server "${SERVER}" "${PORT}"
-  echo update add $(cat "ns4/${ZONE}.zsk")
-  echo send
-) | $NSUPDATE || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# Check the new DNSKEY RRset.
-n=$((n + 1))
-echo_i "check zone ${ZONE} DNSKEY RRset after update ($n)"
-ret=0
-retry_quiet 10 zsks_are_published || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# Check the logs for find zone keys errors.
-n=$((n + 1))
-echo_i "make sure we did not try to sign with the keys added with nsupdate for zone ${ZONE} ($n)"
-ret=0
-grep "dns_zone_findkeys: error reading ./K${ZONE}.*\.private: file not found" "${DIR}/named.run" && ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# Verify again.
-dnssec_verify
-
-n=$((n + 1))
-echo_i "add dnskey record: - update zone ${ZONE} at ns4 with ZSK from provider ns3 ($n)"
-ret=0
-set_server "ns4" "10.53.0.4"
-(
-  echo zone "${ZONE}"
-  echo server "${SERVER}" "${PORT}"
-  echo update add $(cat "ns3/${ZONE}.zsk")
-  echo send
-) | $NSUPDATE || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# Check the new DNSKEY RRset.
-n=$((n + 1))
-echo_i "check zone ${ZONE} DNSKEY RRset after update ($n)"
-ret=0
-retry_quiet 10 zsks_are_published || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# Check the logs for find zone keys errors.
-n=$((n + 1))
-echo_i "make sure we did not try to sign with the keys added with nsupdate for zone ${ZONE} ($n)"
-ret=0
-grep "dns_zone_findkeys: error reading ./K${ZONE}.*\.private: file not found" "${DIR}/named.run" && ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# Verify again.
-dnssec_verify
-no_dnssec_in_journal
-
-n=$((n + 1))
-echo_i "remove dnskey record: - try to remove ns3 ZSK from provider ns3 (should fail) ($n)"
-ret=0
-set_server "ns3" "10.53.0.3"
-(
-  echo zone "${ZONE}"
-  echo server "${SERVER}" "${PORT}"
-  echo update del $(cat "ns3/${ZONE}.zsk")
-  echo send
-) | $NSUPDATE || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# Both ZSKs should still be published.
-n=$((n + 1))
-echo_i "check zone ${ZONE} DNSKEY RRset after failed update ($n)"
-ret=0
-retry_quiet 10 zsks_are_published || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-
-n=$((n + 1))
-echo_i "remove dnskey record: remove ns4 ZSK from provider ns3 ($n)"
-ret=0
-set_server "ns3" "10.53.0.3"
-(
-  echo zone "${ZONE}"
-  echo server "${SERVER}" "${PORT}"
-  echo update del $(cat "ns4/${ZONE}.zsk")
-  echo send
-) | $NSUPDATE || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# We should have only the KSK and ZSK from provider ns3.
-n=$((n + 1))
-echo_i "check zone ${ZONE} DNSKEY RRset after update ($n)"
-ret=0
-check_keys
-check_apex
-dnssec_verify
-
-n=$((n + 1))
-echo_i "remove dnskey record: try to remove ns4 ZSK from provider ns4 (should fail) ($n)"
-ret=0
-set_server "ns4" "10.53.0.4"
-(
-  echo zone "${ZONE}"
-  echo server "${SERVER}" "${PORT}"
-  echo update del $(cat "ns4/${ZONE}.zsk")
-  echo send
-) | $NSUPDATE || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# Both ZSKs should still be published.
-n=$((n + 1))
-echo_i "check zone ${ZONE} DNSKEY RRset after failed update ($n)"
-ret=0
-retry_quiet 10 zsks_are_published || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-
-n=$((n + 1))
-echo_i "remove dnskey record: remove ns3 ZSK from provider ns4 ($n)"
-ret=0
-set_server "ns4" "10.53.0.4"
-(
-  echo zone "${ZONE}"
-  echo server "${SERVER}" "${PORT}"
-  echo update del $(cat "ns3/${ZONE}.zsk")
-  echo send
-) | $NSUPDATE
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# We should have only the KSK and ZSK from provider ns4.
-n=$((n + 1))
-echo_i "check zone ${ZONE} DNSKEY RRset after update ($n)"
-ret=0
-check_keys
-check_apex
-dnssec_verify
-no_dnssec_in_journal
-
-#
-# Update CDNSKEY RRset.
-#
-
 # Check that the CDNSKEY from both providers are published.
 records_published() {
   _rrtype=$1
@@ -278,201 +58,6 @@ records_published() {
   test "$lines" -eq "$_expect" || return 1
 }
 
-# Retrieve CDNSKEY records from the other provider.
-dig_with_opts ${ZONE} @10.53.0.3 CDNSKEY >dig.out.ns3.cdnskey
-awk '$4 == "CDNSKEY" {print}' dig.out.ns3.cdnskey >cdnskey.ns3
-dig_with_opts ${ZONE} @10.53.0.4 CDNSKEY >dig.out.ns4.cdnskey
-awk '$4 == "CDNSKEY" {print}' dig.out.ns4.cdnskey >cdnskey.ns4
-
-n=$((n + 1))
-echo_i "add cdnskey record: update zone ${ZONE} at ns3 with CDNSKEY from provider ns4 ($n)"
-ret=0
-set_server "ns3" "10.53.0.3"
-# Initially there should be one CDNSKEY.
-retry_quiet 10 records_published CDNSKEY 1 || ret=1
-(
-  echo zone "${ZONE}"
-  echo server "${SERVER}" "${PORT}"
-  echo update add $(cat "cdnskey.ns4")
-  echo send
-) | $NSUPDATE || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# Now there should be two CDNSKEY records (we test that BIND does not
-# skip it during DNSSEC maintenance).
-n=$((n + 1))
-echo_i "check zone ${ZONE} CDNSKEY RRset after update ($n)"
-ret=0
-retry_quiet 10 records_published CDNSKEY 2 || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-
-n=$((n + 1))
-echo_i "add cdnskey record: update zone ${ZONE} at ns4 with CDNSKEY from provider ns3 ($n)"
-ret=0
-set_server "ns4" "10.53.0.4"
-# Initially there should be one CDNSKEY.
-retry_quiet 10 records_published CDNSKEY 1 || ret=1
-(
-  echo zone "${ZONE}"
-  echo server "${SERVER}" "${PORT}"
-  echo update add $(cat "cdnskey.ns3")
-  echo send
-) | $NSUPDATE || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# Now there should be two CDNSKEY records (we test that BIND does not
-# skip it during DNSSEC maintenance).
-n=$((n + 1))
-echo_i "check zone ${ZONE} CDNSKEY RRset after update ($n)"
-ret=0
-retry_quiet 10 records_published CDNSKEY 2 || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# No DNSSEC in raw journal.
-no_dnssec_in_journal
-
-n=$((n + 1))
-echo_i "remove cdnskey record: remove ns4 CDNSKEY from provider ns3 ($n)"
-ret=0
-set_server "ns3" "10.53.0.3"
-(
-  echo zone "${ZONE}"
-  echo server "${SERVER}" "${PORT}"
-  echo update del $(cat "cdnskey.ns4")
-  echo send
-) | $NSUPDATE || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# Now there should be one CDNSKEY record again.
-n=$((n + 1))
-echo_i "check zone ${ZONE} CDNSKEY RRset after update ($n)"
-ret=0
-retry_quiet 10 records_published CDNSKEY 1 || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-
-n=$((n + 1))
-echo_i "remove cdnskey record: remove ns3 CDNSKEY from provider ns4 ($n)"
-ret=0
-set_server "ns4" "10.53.0.4"
-(
-  echo zone "${ZONE}"
-  echo server "${SERVER}" "${PORT}"
-  echo update del $(cat "cdnskey.ns3")
-  echo send
-) | $NSUPDATE || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# Now there should be one CDNSKEY record again.
-n=$((n + 1))
-echo_i "check zone ${ZONE} CDNSKEY RRset after update ($n)"ret=0
-retry_quiet 10 records_published CDNSKEY 1 || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# No DNSSEC in raw journal.
-no_dnssec_in_journal
-
-#
-# Update CDS RRset.
-#
-
-# Retrieve CDS records from the other provider.
-dig_with_opts ${ZONE} @10.53.0.3 CDS >dig.out.ns3.cds
-awk '$4 == "CDS" {print}' dig.out.ns3.cds >cds.ns3
-dig_with_opts ${ZONE} @10.53.0.4 CDS >dig.out.ns4.cds
-awk '$4 == "CDS" {print}' dig.out.ns4.cds >cds.ns4
-
-n=$((n + 1))
-echo_i "add cds record: update zone ${ZONE} at ns3 with CDS from provider ns4 ($n)"
-ret=0
-set_server "ns3" "10.53.0.3"
-# Initially there should be one CDS.
-retry_quiet 10 records_published CDS 1 || ret=1
-(
-  echo zone "${ZONE}"
-  echo server "${SERVER}" "${PORT}"
-  echo update add $(cat "cds.ns4")
-  echo send
-) | $NSUPDATE || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# Now there should be two CDS records (we test that BIND does not
-# skip it during DNSSEC maintenance).
-n=$((n + 1))
-echo_i "check zone ${ZONE} CDS RRset after update ($n)"
-ret=0
-retry_quiet 10 records_published CDS 2 || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-
-n=$((n + 1))
-echo_i "add cds record: update zone ${ZONE} at ns4 with CDS from provider ns3 ($n)"
-ret=0
-set_server "ns4" "10.53.0.4"
-# Initially there should be one CDS.
-retry_quiet 10 records_published CDS 1 || ret=1
-(
-  echo zone "${ZONE}"
-  echo server "${SERVER}" "${PORT}"
-  echo update add $(cat "cds.ns3")
-  echo send
-) | $NSUPDATE || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# Now there should be two CDS records (we test that BIND does not
-# skip it during DNSSEC maintenance).
-n=$((n + 1))
-echo_i "check zone ${ZONE} CDS RRset after update ($n)"
-ret=0
-retry_quiet 10 records_published CDS 2 || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# No DNSSEC in raw journal.
-no_dnssec_in_journal
-
-n=$((n + 1))
-echo_i "remove cds record: remove ns4 CDS from provider ns3 ($n)"
-ret=0
-set_server "ns3" "10.53.0.3"
-(
-  echo zone "${ZONE}"
-  echo server "${SERVER}" "${PORT}"
-  echo update del $(cat "cds.ns4")
-  echo send
-) | $NSUPDATE || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# Now there should be one CDS record again.
-n=$((n + 1))
-echo_i "check zone ${ZONE} CDS RRset after update ($n)"
-ret=0
-retry_quiet 10 records_published CDS 1 || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-
-n=$((n + 1))
-echo_i "remove cds record: remove ns3 CDS from provider ns4 ($n)"
-ret=0
-set_server "ns4" "10.53.0.4"
-(
-  echo zone "${ZONE}"
-  echo server "${SERVER}" "${PORT}"
-  echo update del $(cat "cds.ns3")
-  echo send
-) | $NSUPDATE || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# Now there should be one CDS record again.
-n=$((n + 1))
-echo_i "check zone ${ZONE} CDS RRset after update ($n)"
-ret=0
-retry_quiet 10 records_published CDS 1 || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-# No DNSSEC in raw journal.
-no_dnssec_in_journal
-
 #
 # Check secondary server behaviour.
 #
diff --git a/bin/tests/system/multisigner/tests_multisigner.py b/bin/tests/system/multisigner/tests_multisigner.py
new file mode 100644 (file)
index 0000000..9638bc3
--- /dev/null
@@ -0,0 +1,454 @@
+# 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 datetime import timedelta
+import os
+import re
+
+import pytest
+
+pytest.importorskip("dns", minversion="2.0.0")
+import dns
+import dns.update
+
+import isctest
+
+pytestmark = pytest.mark.extra_artifacts(
+    [
+        "*.axfr",
+        "*.created",
+        "cdnskey.ns*",
+        "cds.ns*",
+        "dig.out.*",
+        "rndc.dnssec.status.out.*",
+        "secondary.cdnskey.ns*",
+        "secondary.cds.ns*",
+        "unused.*",
+        "verify.out.*",
+        "ns*/K*",
+        "ns*/db-*",
+        "ns*/keygen.out.*",
+        "ns*/*.jbk",
+        "ns*/*.jnl",
+        "ns*/*.zsk",
+        "ns*/*.signed",
+        "ns*/*.journal.out.*",
+        "ns*/settime.out.*",
+        "ns*/model2.secondary.db",
+    ]
+)
+
+ALGORITHM = os.environ["DEFAULT_ALGORITHM_NUMBER"]
+SIZE = os.environ["DEFAULT_BITS"]
+CONFIG = {
+    "dnskey-ttl": timedelta(hours=1),
+    "ds-ttl": timedelta(days=1),
+    "max-zone-ttl": timedelta(days=1),
+    "parent-propagation-delay": timedelta(hours=1),
+    "publish-safety": timedelta(hours=1),
+    "retire-safety": timedelta(hours=1),
+    "signatures-refresh": timedelta(days=5),
+    "signatures-validity": timedelta(days=14),
+    "zone-propagation-delay": timedelta(minutes=5),
+}
+TTL = 3600
+
+
+def dsfromkey(key):
+    dsfromkey_command = [
+        os.environ.get("DSFROMKEY"),
+        "-T",
+        str(TTL),
+        "-a",
+        "SHA-256",
+        "-C",
+        "-w",
+        str(key.keyfile),
+    ]
+    out = isctest.run.cmd(dsfromkey_command)
+    return out.stdout.decode("utf-8").split()
+
+
+def check_dnssec(server, zone, keys, expected):
+    ksks = [k for k in keys if k.is_ksk()]
+    zsks = [k for k in keys if not k.is_ksk()]
+
+    isctest.kasp.check_keys(zone, keys, expected)
+
+    for kp in expected:
+        kp.set_expected_keytimes(CONFIG)
+        kp.set_expected_keytimes(CONFIG)
+        start = kp.key.get_timing("Created")
+        kp.timing["Published"] = start
+        kp.timing["Active"] = start
+        if kp.role != "zsk":
+            kp.timing["PublishCDS"] = start
+
+    isctest.kasp.check_dnssec_verify(server, zone)
+    isctest.kasp.check_apex(server, zone, ksks, zsks)
+
+
+def check_no_dnssec_in_journal(server, zone):
+    journalprint = [
+        os.environ.get("JOURNALPRINT"),
+        f"{server.identifier}/{zone}.db.jnl",
+    ]
+
+    out = isctest.run.cmd(journalprint)
+    contents = out.stdout.decode("utf-8")
+    pattern = re.compile(
+        r"^\s*(?:\S+\s+){4}(NSEC|NSEC3|NSEC3PARAM|RRSIG)", flags=re.MULTILINE
+    )
+    match = pattern.search(contents)
+    assert not match, f"{match.group(1)} record found in journal"
+
+
+def check_add_zsk(server, zone, keys, expected, zsk, extra):
+    isctest.log.info("add dnskey record:")
+
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: update zone with ZSK from other provider"
+    )
+
+    dnskey = zsk.dnskey().split()
+    rdata = " ".join(dnskey[4:])
+    update_msg = dns.update.UpdateMessage(zone)
+    update_msg.add(f"{zone}.", TTL, "DNSKEY", rdata)
+    server.nsupdate(update_msg)
+
+    # Check the new DNSKEY RRset.
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: check DNSKEY RRset after update add"
+    )
+    check_dnssec(server, zone, keys + [zsk], expected + extra)
+
+    # Check the logs for find zone keys errors.
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: make sure we did not try to sign with the keys added with nsupdate"
+    )
+    server.log.prohibit(f"dns_zone_findkeys: error reading ./K{zone}")
+
+    # Trigger keymgr.
+    with server.watch_log_from_here() as watcher:
+        server.rndc(f"loadkeys {zone}", log=False)
+        watcher.wait_for_line(f"keymgr: {zone} done")
+
+    # Check again.
+    isctest.log.info(f"- zone {zone} {server.identifier}: check again after keymgr run")
+    check_dnssec(server, zone, keys + [zsk], expected + extra)
+    server.log.prohibit(f"dns_zone_findkeys: error reading ./K{zone}")
+
+
+def check_remove_zsk(server, zone, keys, expected, zsk, extra):
+    isctest.log.info("remove dnskey record:")
+
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: try to remove own ZSK (should fail)"
+    )
+
+    zsks = [k for k in keys if not k.is_ksk()]
+    dnskey = zsks[0].dnskey().split()
+    rdata = " ".join(dnskey[4:])
+    update_msg = dns.update.UpdateMessage(zone)
+    update_msg.delete(f"{zone}.", "DNSKEY", rdata)
+    with server.watch_log_from_here() as watcher:
+        server.nsupdate(update_msg)
+        watcher.wait_for_line(
+            f"updating zone '{zone}/IN': attempt to delete in use DNSKEY ignored"
+        )
+
+    # Both ZSKs should still be published.
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: check DNSKEY RRset after update remove"
+    )
+    check_dnssec(server, zone, keys + [zsk], expected + extra)
+
+    # Trigger keymgr.
+    with server.watch_log_from_here() as watcher:
+        server.rndc(f"loadkeys {zone}", log=False)
+        watcher.wait_for_line(f"keymgr: {zone} done")
+
+    # Check again.
+    isctest.log.info(f"- zone {zone} {server.identifier}: check again after keymgr run")
+    check_dnssec(server, zone, keys + [zsk], expected + extra)
+
+    # Remove actual ZSK.
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: remove ZSK from other provider"
+    )
+
+    dnskey = zsk.dnskey().split()
+    rdata = " ".join(dnskey[4:])
+    update_msg = dns.update.UpdateMessage(zone)
+    update_msg.delete(f"{zone}.", "DNSKEY", rdata)
+    server.nsupdate(update_msg)
+
+    # We should have only the KSK and ZSK from server.
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: check DNSKEY RRset after update remove"
+    )
+    check_dnssec(server, zone, keys, expected)
+
+    # Trigger keymgr.
+    with server.watch_log_from_here() as watcher:
+        server.rndc(f"loadkeys {zone}", log=False)
+        watcher.wait_for_line(f"keymgr: {zone} done")
+
+    # Check again.
+    isctest.log.info(f"- zone {zone} {server.identifier}: check again after keymgr run")
+    check_dnssec(server, zone, keys, expected)
+
+
+def check_add_cdnskey(server, zone, keys, expected, ksk, extra):
+    isctest.log.info("add cdnskey record:")
+
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: update zone with CDNSKEY from other provider"
+    )
+
+    # Retrieve CDNSKEY records from the other provider.
+    dnskey = ksk.dnskey().split()
+    rdata = " ".join(dnskey[4:])
+    update_msg = dns.update.UpdateMessage(zone)
+    update_msg.add(f"{zone}.", TTL, "CDNSKEY", rdata)
+    server.nsupdate(update_msg)
+
+    # Now there should be two CDNSKEY records.
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: check CDNSKEY RRset after update add"
+    )
+    check_dnssec(server, zone, keys + [ksk], expected + extra)
+
+    # Trigger keymgr.
+    with server.watch_log_from_here() as watcher:
+        server.rndc(f"loadkeys {zone}", log=False)
+        watcher.wait_for_line(f"keymgr: {zone} done")
+
+    # Check again.
+    isctest.log.info(f"- zone {zone} {server.identifier}: check again after keymgr run")
+    check_dnssec(server, zone, keys + [ksk], expected + extra)
+
+
+def check_remove_cdnskey(server, zone, keys, expected, ksk, extra):
+    isctest.log.info("remove cdnskey record:")
+
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: try to remove own CDNSKEY (should fail)"
+    )
+
+    ksks = [k for k in keys if not k.is_ksk()]
+    dnskey = ksks[0].dnskey().split()
+    rdata = " ".join(dnskey[4:])
+    update_msg = dns.update.UpdateMessage(zone)
+    update_msg.delete(f"{zone}.", "CDNSKEY", rdata)
+    with server.watch_log_from_here() as watcher:
+        server.nsupdate(update_msg)
+        watcher.wait_for_line(
+            f"updating zone '{zone}/IN': attempt to delete in use CDNSKEY ignored"
+        )
+
+    # Both CDNSKEY records should still be published.
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: check CDNSKEY RRset after update remove"
+    )
+    check_dnssec(server, zone, keys + [ksk], expected + extra)
+
+    # Trigger keymgr.
+    with server.watch_log_from_here() as watcher:
+        server.rndc(f"loadkeys {zone}", log=False)
+        watcher.wait_for_line(f"keymgr: {zone} done")
+
+    # Check again.
+    isctest.log.info(f"- zone {zone} {server.identifier}: check again after keymgr run")
+    check_dnssec(server, zone, keys + [ksk], expected + extra)
+
+    # Remove actual CDNSKEY.
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: remove CDNSKEY from other provider"
+    )
+
+    dnskey = ksk.dnskey().split()
+    rdata = " ".join(dnskey[4:])
+    update_msg = dns.update.UpdateMessage(zone)
+    update_msg.delete(f"{zone}.", "CDNSKEY", rdata)
+    server.nsupdate(update_msg)
+
+    # Now there should be one CDNSKEY record again.
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: check CDNSKEY RRset after update remove"
+    )
+    check_dnssec(server, zone, keys, expected)
+
+    # Trigger keymgr.
+    with server.watch_log_from_here() as watcher:
+        server.rndc(f"loadkeys {zone}", log=False)
+        watcher.wait_for_line(f"keymgr: {zone} done")
+
+    # Check again.
+    isctest.log.info(f"- zone {zone} {server.identifier}: check again after keymgr run")
+    check_dnssec(server, zone, keys, expected)
+
+
+def check_add_cds(server, zone, keys, expected, ksk, extra):
+    isctest.log.info("add cds record:")
+
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: update zone with CDS from other provider"
+    )
+
+    # Retrieve CDS records from the other provider.
+    ds = dsfromkey(ksk)
+    rdata = " ".join(ds[4:])
+    update_msg = dns.update.UpdateMessage(zone)
+    update_msg.add(f"{zone}.", TTL, "CDS", rdata)
+    server.nsupdate(update_msg)
+
+    # Now there should be two CDS records.
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: check CDS RRset after update add"
+    )
+    check_dnssec(server, zone, keys + [ksk], expected + extra)
+
+    # Trigger keymgr.
+    with server.watch_log_from_here() as watcher:
+        server.rndc(f"loadkeys {zone}", log=False)
+        watcher.wait_for_line(f"keymgr: {zone} done")
+
+    # Check again.
+    isctest.log.info(f"- zone {zone} {server.identifier}: check again after keymgr run")
+    check_dnssec(server, zone, keys + [ksk], expected + extra)
+
+
+def check_remove_cds(server, zone, keys, expected, ksk, extra):
+    isctest.log.info("remove cds record:")
+
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: try to remove own CDS (should fail)"
+    )
+
+    ksks = [k for k in keys if not k.is_ksk()]
+    ds = dsfromkey(ksks[0])
+    rdata = " ".join(ds[4:])
+    update_msg = dns.update.UpdateMessage(zone)
+    update_msg.delete(f"{zone}.", "CDS", rdata)
+    with server.watch_log_from_here() as watcher:
+        server.nsupdate(update_msg)
+        watcher.wait_for_line(
+            f"updating zone '{zone}/IN': attempt to delete in use CDS ignored"
+        )
+
+    # Both CDS records should still be published.
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: check CDS RRset after update remove"
+    )
+    check_dnssec(server, zone, keys + [ksk], expected + extra)
+
+    # Trigger keymgr.
+    with server.watch_log_from_here() as watcher:
+        server.rndc(f"loadkeys {zone}", log=False)
+        watcher.wait_for_line(f"keymgr: {zone} done")
+
+    # Check again.
+    isctest.log.info(f"- zone {zone} {server.identifier}: check again after keymgr run")
+    check_dnssec(server, zone, keys + [ksk], expected + extra)
+
+    # Remove actual CDS.
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: remove CDS from other provider"
+    )
+
+    ds = dsfromkey(ksk)
+    rdata = " ".join(ds[4:])
+    update_msg = dns.update.UpdateMessage(zone)
+    update_msg.delete(f"{zone}.", "CDS", rdata)
+    server.nsupdate(update_msg)
+
+    # Now there should be one CDS record again.
+    isctest.log.info(
+        f"- zone {zone} {server.identifier}: check CDS RRset after update remove"
+    )
+    check_dnssec(server, zone, keys, expected)
+
+    # Trigger keymgr.
+    with server.watch_log_from_here() as watcher:
+        server.rndc(f"loadkeys {zone}", log=False)
+        watcher.wait_for_line(f"keymgr: {zone} done")
+
+    # Check again.
+    isctest.log.info(f"- zone {zone} {server.identifier}: check again after keymgr run")
+    check_dnssec(server, zone, keys, expected)
+
+
+def test_multisigner(ns3, ns4):
+    zone = "model2.multisigner"
+    keyprops = [
+        f"ksk 0 {ALGORITHM} {SIZE} goal:omnipresent dnskey:omnipresent krrsig:omnipresent ds:omnipresent",
+        f"zsk 0 {ALGORITHM} {SIZE} goal:omnipresent dnskey:omnipresent zrrsig:omnipresent",
+    ]
+
+    # First make sure the zone is properly signed.
+    isctest.log.info(f"basic DNSSEC tests for {zone}")
+    isctest.kasp.wait_keymgr_done(ns3, zone)
+    isctest.kasp.wait_keymgr_done(ns4, zone)
+
+    keys3 = isctest.kasp.keydir_to_keylist(zone, ns3.identifier)
+    ksks3 = [k for k in keys3 if k.is_ksk()]
+    zsks3 = [k for k in keys3 if not k.is_ksk()]
+    expected3 = isctest.kasp.policy_to_properties(ttl=TTL, keys=keyprops)
+
+    check_dnssec(ns3, zone, keys3, expected3)
+
+    keys4 = isctest.kasp.keydir_to_keylist(zone, ns4.identifier)
+    ksks4 = [k for k in keys4 if k.is_ksk()]
+    zsks4 = [k for k in keys4 if not k.is_ksk()]
+    expected4 = isctest.kasp.policy_to_properties(ttl=TTL, keys=keyprops)
+
+    check_dnssec(ns4, zone, keys4, expected4)
+
+    # Add DNSKEY to RRset.
+    newprops = [f"zsk unlimited {ALGORITHM} {SIZE}"]
+    extra = isctest.kasp.policy_to_properties(ttl=TTL, keys=newprops)
+    extra[0].private = False  # noqa
+    extra[0].legacy = True  # noqa
+
+    check_add_zsk(ns3, zone, keys3, expected3, zsks4[0], extra)
+    check_add_zsk(ns4, zone, keys4, expected4, zsks3[0], extra)
+    check_no_dnssec_in_journal(ns4, zone)
+
+    # Remove DNSKEY from RRset.
+    check_remove_zsk(ns3, zone, keys3, expected3, zsks4[0], extra)
+    check_remove_zsk(ns4, zone, keys4, expected4, zsks3[0], extra)
+    check_no_dnssec_in_journal(ns4, zone)
+
+    # Add CDNSKEY RRset.
+    newprops = [f"ksk unlimited {ALGORITHM} {SIZE}"]
+    extra = isctest.kasp.policy_to_properties(ttl=TTL, keys=newprops)
+    extra[0].private = False  # noqa
+    extra[0].legacy = True  # noqa
+
+    check_add_cdnskey(ns3, zone, keys3, expected3, ksks4[0], extra)
+    check_add_cdnskey(ns4, zone, keys4, expected4, ksks3[0], extra)
+    check_no_dnssec_in_journal(ns4, zone)
+
+    # Remove CDNSKEY RRset.
+    check_remove_cdnskey(ns3, zone, keys3, expected3, ksks4[0], extra)
+    check_remove_cdnskey(ns4, zone, keys4, expected4, ksks3[0], extra)
+    check_no_dnssec_in_journal(ns4, zone)
+
+    # Update CDS RRset.
+    check_add_cds(ns3, zone, keys3, expected3, ksks4[0], extra)
+    check_add_cds(ns4, zone, keys4, expected4, ksks3[0], extra)
+    check_no_dnssec_in_journal(ns4, zone)
+
+    # Remove CDS RRset.
+    check_remove_cds(ns3, zone, keys3, expected3, ksks4[0], extra)
+    check_remove_cds(ns4, zone, keys4, expected4, ksks3[0], extra)
+    check_no_dnssec_in_journal(ns4, zone)