]> git.ipfire.org Git - thirdparty/bind9.git/commitdiff
Convert kasp multi-signer tests to pytest
authorMatthijs Mekking <matthijs@isc.org>
Tue, 18 Mar 2025 07:41:02 +0000 (08:41 +0100)
committerMatthijs Mekking <matthijs@isc.org>
Mon, 2 Jun 2025 09:21:06 +0000 (09:21 +0000)
Move the multi-signer test scenarios to the rollover directory and
convert tests to pytest.

- If the KeyProperties set the "legacy" to True, don't set expected
  key times, nor check them. Also, when a matching key is found, set
  key.external to True.
- External keys don't show up in the 'rndc dnssec -status' output so
  skip them in the 'check_dnssecstatus' function. External keys never
  sign RRsets, so also skip those keys in the '_check_signatures'
  function.
- Key properties strings now can set expected key tag ranges, and if
  KeyProperties have tag ranges set, they are checked.

bin/tests/system/isctest/kasp.py
bin/tests/system/kasp/ns3/named-fips.conf.in
bin/tests/system/kasp/ns3/policies/kasp-fips.conf.in
bin/tests/system/kasp/ns3/setup.sh
bin/tests/system/kasp/tests.sh
bin/tests/system/rollover/ns3/kasp.conf.j2
bin/tests/system/rollover/ns3/named.conf.j2
bin/tests/system/rollover/ns3/setup.sh
bin/tests/system/rollover/tests_rollover.py

index d6bd930d87745955548ebf8cbee7b1e36c7f7ca8..35d8c6e82a9d01b187a03f13b607769fa455d161 100644 (file)
@@ -291,6 +291,7 @@ class Key:
         self.keyfile = f"{self.path}.key"
         self.statefile = f"{self.path}.state"
         self.tag = int(self.name[-5:])
+        self.external = False
 
     def get_timing(
         self, metadata: str, must_exist: bool = True
@@ -572,6 +573,14 @@ class Key:
             if not self.is_metadata_consistent(key, properties.metadata):
                 return False
 
+        # Check tag range.
+        if "keytag-min" in properties.properties:
+            if self.tag < properties.properties["keytag-min"]:
+                return False
+        if "keytag-max" in properties.properties:
+            if self.tag > properties.properties["keytag-max"]:
+                return False
+
         # A match is found.
         return True
 
@@ -767,7 +776,8 @@ def check_dnssecstatus(server, zone, keys, policy=None, view=None):
     assert f"dnssec-policy: {policy}" in response
 
     for key in keys:
-        assert f"key: {key.tag}" in response
+        if not key.external:
+            assert f"key: {key.tag}" in response
 
 
 def _check_signatures(
@@ -780,6 +790,9 @@ def _check_signatures(
     krrsig = not zrrsig
 
     for key in keys:
+        if key.external:
+            continue
+
         ksigning, zsigning = key.get_signing_state(
             offline_ksk=offline_ksk, zsk_missing=zsk_missing
         )
@@ -1248,6 +1261,7 @@ def policy_to_properties(ttl, keys: List[str]) -> List[KeyProperties]:
       sets the given state to the specific value
     - "missing", set if the private key file for this key is not available.
     - "offset", an offset for testing key rollover timings
+    - "tag-range", followed by <min>-<max> to test key tag ranges
     """
     proplist = []
     count = 0
@@ -1297,6 +1311,11 @@ def policy_to_properties(ttl, keys: List[str]) -> List[KeyProperties]:
             elif line[i].startswith("offset:"):
                 keyval = line[i].split(":")
                 keyprop.properties["offset"] = timedelta(seconds=int(keyval[1]))
+            elif line[i].startswith("tag-range:"):
+                keyval = line[i].split(":")
+                tagrange = keyval[1].split("-")
+                keyprop.properties["keytag-min"] = int(tagrange[0])
+                keyprop.properties["keytag-max"] = int(tagrange[1])
             elif line[i] == "missing":
                 keyprop.properties["private"] = False
             else:
index 92e153688464d36b9092f70e0b6d762fab9c8907..8368c4822e738b8401bdd8925c5e46842d5bf768 100644 (file)
@@ -204,25 +204,6 @@ zone "rumoured.kasp" {
        dnssec-policy "rsasha256";
 };
 
-/* RFC 8901 Multi-signer Model 2. */
-zone "multisigner-model2.kasp" {
-       type primary;
-       file "multisigner-model2.kasp.db";
-       dnssec-policy "multisigner-model2";
-       allow-update { any; };
-};
-
-/*
- * A zone that starts with keys that have tags that are
- * outside of the desired multi-signer key tag range.
- */
-zone "single-to-multisigner.kasp" {
-       type primary;
-       file "single-to-multisigner.kasp.db";
-       dnssec-policy "multisigner-model2";
-       allow-update { any; };
-};
-
 /*
  * Different algorithms.
  */
index 2649b056d86eb5ba176486541e0ef4b0074e82e5..68f932bcf10f8a4bdbddbb6a779d22c01df9745e 100644 (file)
@@ -23,16 +23,6 @@ dnssec-policy "default-dynamic" {
        inline-signing no;
 };
 
-dnssec-policy "multisigner-model2" {
-       dnskey-ttl 3600;
-       inline-signing no;
-
-       keys {
-               ksk key-directory lifetime unlimited algorithm @DEFAULT_ALGORITHM@ tag-range 32768 65535;
-               zsk key-directory lifetime unlimited algorithm @DEFAULT_ALGORITHM@ tag-range 32768 65535;
-       };
-};
-
 dnssec-policy "migrate-to-dnssec-policy" {
        dnskey-ttl 1234;
 
index 5432bfecc85f00c8bf67a13824d7babe8cecd40a..7eeb2488384b6b6beaa11964408b556cfbcdc2d1 100644 (file)
@@ -50,7 +50,7 @@ for zn in default dnssec-keygen some-keys legacy-keys pregenerated \
   rumoured rsasha256 rsasha512 ecdsa256 ecdsa384 \
   dynamic dynamic-inline-signing inline-signing \
   checkds-ksk checkds-doubleksk checkds-csk inherit unlimited \
-  multisigner-model2 keystore; do
+  keystore; do
   setup "${zn}.kasp"
   cp template.db.in "$zonefile"
 done
@@ -127,23 +127,6 @@ echo_i "setting up zone: $zone"
 $KEYGEN -G -k rsasha256 -l policies/kasp.conf $zone >keygen.out.$zone.1 2>&1
 $KEYGEN -G -k rsasha256 -l policies/kasp.conf $zone >keygen.out.$zone.2 2>&1
 
-zone="multisigner-model2.kasp"
-echo_i "setting up zone: $zone"
-KSK=$($KEYGEN -a $DEFAULT_ALGORITHM -f KSK -L 3600 -M 32768:65535 $zone 2>keygen.out.$zone.1)
-ZSK=$($KEYGEN -a $DEFAULT_ALGORITHM -L 3600 -M 32768:65535 $zone 2>keygen.out.$zone.2)
-cat "${KSK}.key" | grep -v ";.*" >>"${zone}.db"
-cat "${ZSK}.key" | grep -v ";.*" >>"${zone}.db"
-# Import the ZSK sets of the other providers into their DNSKEY RRset.
-# ZSK1 is from a different provider and is added to the unsigned zonefile.
-# ZSK2 is also from a different provider and is added with a Dynamic Update.
-ZSK1=$($KEYGEN -K ../ -a $DEFAULT_ALGORITHM -L 3600 -M 0:32767 $zone 2>keygen.out.$zone.3)
-ZSK2=$($KEYGEN -K ../ -a $DEFAULT_ALGORITHM -L 3600 -M 0:32767 $zone 2>keygen.out.$zone.4)
-cat "../${ZSK1}.key" | grep -v ";.*" >>"${zone}.db"
-cat "../${ZSK1}.key" | grep -v ";.*" >"${zone}.zsk1"
-cat "../${ZSK2}.key" | grep -v ";.*" >"${zone}.zsk2"
-rm -f "../${ZSK1}.*"
-rm -f "../${ZSK2}.*"
-
 zone="rumoured.kasp"
 echo_i "setting up zone: $zone"
 Tpub="now"
@@ -174,17 +157,6 @@ private_type_record $zone $DEFAULT_ALGORITHM_NUMBER "keys/$CSK" >>"$infile"
 cp $infile $zonefile
 $SIGNER -PS -K keys -z -x -s now-2w -e now-1mi -o $zone -f "${zonefile}.signed" $infile >signer.out.$zone.1 2>&1
 
-# We are changing an existing single-signed zone to multi-signed
-# zone where the key tags do not match the dnssec-policy key tag range
-setup single-to-multisigner.kasp
-T="now-1d"
-KSK=$($KEYGEN -a $DEFAULT_ALGORITHM -M 0:32767 -L 3600 -f KSK $ksktimes $zone 2>keygen.out.$zone.1)
-ZSK=$($KEYGEN -a $DEFAULT_ALGORITHM -M 0:32767 -L 3600 $zsktimes $zone 2>keygen.out.$zone.2)
-$SETTIME -s -g $O -d $O $T -k $O $T -r $O $T "$KSK" >settime.out.$zone.1 2>&1
-$SETTIME -s -g $O -k $O $T -z $O $T "$ZSK" >settime.out.$zone.2 2>&1
-cat template.db.in "${KSK}.key" "${ZSK}.key" >"$infile"
-$SIGNER -PS -z -x -s now-2w -e now-1mi -o $zone -f "${zonefile}" $infile >signer.out.$zone.1 2>&1
-
 # Treat the next zones as if they were signed six months ago.
 T="now-6mo"
 keytimes="-P $T -A $T"
index 47af94bad1792af0bfd9816a2a5a9ead39c9d6d2..b83b6a16d6fad3278d0a3f290af4ca4f2b44549c 100644 (file)
@@ -206,169 +206,6 @@ set_keytimes_autosign_policy() {
   set_addkeytime "KEY2" "REMOVED" "${retired}" 695100
 }
 
-#
-# Testing RFC 8901 Multi-Signer Model 2.
-#
-set_zone "multisigner-model2.kasp"
-set_policy "multisigner-model2" "2" "3600"
-set_server "ns3" "10.53.0.3"
-key_clear "KEY1"
-key_clear "KEY2"
-key_clear "KEY3"
-key_clear "KEY4"
-
-# Key properties.
-set_keyrole "KEY1" "ksk"
-set_keylifetime "KEY1" "0"
-set_keyalgorithm "KEY1" "$DEFAULT_ALGORITHM_NUMBER" "$DEFAULT_ALGORITHM" "$DEFAULT_BITS"
-set_keysigning "KEY1" "yes"
-set_zonesigning "KEY1" "no"
-
-set_keyrole "KEY2" "zsk"
-set_keylifetime "KEY2" "0"
-set_keyalgorithm "KEY2" "$DEFAULT_ALGORITHM_NUMBER" "$DEFAULT_ALGORITHM" "$DEFAULT_BITS"
-set_keysigning "KEY2" "no"
-set_zonesigning "KEY2" "yes"
-
-set_keystate "KEY1" "GOAL" "omnipresent"
-set_keystate "KEY1" "STATE_DNSKEY" "rumoured"
-set_keystate "KEY1" "STATE_KRRSIG" "rumoured"
-set_keystate "KEY1" "STATE_DS" "hidden"
-set_keystate "KEY2" "GOAL" "omnipresent"
-set_keystate "KEY2" "STATE_DNSKEY" "rumoured"
-set_keystate "KEY2" "STATE_ZRRSIG" "rumoured"
-
-check_keys
-check_dnssecstatus "$SERVER" "$POLICY" "$ZONE"
-check_apex
-check_subdomain
-dnssec_verify
-
-# Check that the ZSKs from the other providers are published.
-zsks_are_published() {
-  num=$1
-  dig_with_opts +short "$ZONE" "@${SERVER}" DNSKEY >"dig.out.$DIR.test$n" || return 1
-  # We should have three ZSKs.
-  lines=$(grep "256 3 13" dig.out.$DIR.test$n | wc -l)
-  test "$lines" -eq $num || return 1
-  # And one KSK.
-  lines=$(grep "257 3 13" dig.out.$DIR.test$n | wc -l)
-  test "$lines" -eq 1 || return 1
-}
-n=$((n + 1))
-echo_i "check initial number of ZSKs (one from us and one from another provider) for zone ${ZONE} ($n)"
-ret=0
-retry_quiet 10 zsks_are_published 2 || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-
-n=$((n + 1))
-echo_i "update zone with ZSK from another provider for zone ${ZONE} ($n)"
-ret=0
-(
-  echo zone ${ZONE}
-  echo server 10.53.0.3 "$PORT"
-  echo update add $(cat "${DIR}/${ZONE}.zsk2")
-  echo send
-) | $NSUPDATE
-retry_quiet 10 zsks_are_published 3 || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-
-n=$((n + 1))
-echo_i "remove ZSKs from the other providers for zone ${ZONE} ($n)"
-ret=0
-(
-  echo zone ${ZONE}
-  echo server 10.53.0.3 "$PORT"
-  echo update del $(cat "${DIR}/${ZONE}.zsk1")
-  echo update del $(cat "${DIR}/${ZONE}.zsk2")
-  echo send
-) | $NSUPDATE
-retry_quiet 10 zsks_are_published 1 || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-
-#
-# A zone transitioning from single-signed to multi-signed.
-# We should have the old omnipresent keys outside of the
-# desired key range and the new keys in the desired key range
-# KEY1 and KEY2 are the new keys. KEY3 and KEY4 are the old keys.
-#
-set_zone "single-to-multisigner.kasp"
-set_policy "multisigner-model2" "4" "3600"
-set_server "ns3" "10.53.0.3"
-key_clear "KEY1"
-key_clear "KEY2"
-key_clear "KEY3"
-key_clear "KEY4"
-
-# Key properties.
-set_keyrole "KEY1" "ksk"
-set_keylifetime "KEY1" "0"
-set_keyalgorithm "KEY1" "$DEFAULT_ALGORITHM_NUMBER" "$DEFAULT_ALGORITHM" "$DEFAULT_BITS"
-set_keysigning "KEY1" "yes"
-set_zonesigning "KEY1" "no"
-
-set_keyrole "KEY2" "zsk"
-set_keylifetime "KEY2" "0"
-set_keyalgorithm "KEY2" "$DEFAULT_ALGORITHM_NUMBER" "$DEFAULT_ALGORITHM" "$DEFAULT_BITS"
-set_keysigning "KEY2" "no"
-set_zonesigning "KEY2" "no" # waiting for DNSKEY to be omnipresent
-
-set_keyrole "KEY3" "ksk"
-set_keyalgorithm "KEY3" "$DEFAULT_ALGORITHM_NUMBER" "$DEFAULT_ALGORITHM" "$DEFAULT_BITS"
-set_keysigning "KEY3" "yes"
-set_zonesigning "KEY3" "no"
-
-set_keyrole "KEY4" "zsk"
-set_keyalgorithm "KEY4" "$DEFAULT_ALGORITHM_NUMBER" "$DEFAULT_ALGORITHM" "$DEFAULT_BITS"
-set_keysigning "KEY4" "no"
-set_zonesigning "KEY4" "yes"
-
-set_keystate "KEY1" "GOAL" "omnipresent"
-set_keystate "KEY1" "STATE_DNSKEY" "rumoured"
-set_keystate "KEY1" "STATE_KRRSIG" "rumoured"
-set_keystate "KEY1" "STATE_DS" "hidden"
-
-set_keystate "KEY2" "GOAL" "omnipresent"
-set_keystate "KEY2" "STATE_DNSKEY" "rumoured"
-set_keystate "KEY2" "STATE_ZRRSIG" "hidden" # waiting for DNSKEY to be omnipresent
-
-set_keystate "KEY3" "GOAL" "hidden"
-set_keystate "KEY3" "STATE_DNSKEY" "omnipresent"
-set_keystate "KEY3" "STATE_KRRSIG" "omnipresent"
-set_keystate "KEY3" "STATE_DS" "omnipresent"
-
-set_keystate "KEY4" "GOAL" "hidden"
-set_keystate "KEY4" "STATE_DNSKEY" "omnipresent"
-set_keystate "KEY4" "STATE_ZRRSIG" "omnipresent"
-
-check_keys
-check_dnssecstatus "$SERVER" "$POLICY" "$ZONE"
-check_apex
-check_subdomain
-dnssec_verify
-
-# KEY1 tag range 32768 65535
-# KEY2 tag range 32768 65535
-# KEY3 tag range 0 32767
-# KEY4 tag range 0 32767
-n=$((n + 1))
-echo_i "check that the key IDs are in the expected ranges ($n)"
-ret=0
-test $(key_get KEY1 ID) -ge 32768 -a $(key_get KEY1 ID) -le 65535 || ret=1
-test $(key_get KEY2 ID) -ge 32768 -a $(key_get KEY2 ID) -le 65535 || ret=1
-test $(key_get KEY3 ID) -ge 0 -a $(key_get KEY3 ID) -le 32767 || ret=1
-test $(key_get KEY4 ID) -ge 0 -a $(key_get KEY4 ID) -le 32767 || ret=1
-
-test $(key_get KEY1 RID) -ge 32768 -a $(key_get KEY1 RID) -le 65535 || ret=1
-test $(key_get KEY2 RID) -ge 32768 -a $(key_get KEY2 RID) -le 65535 || ret=1
-test $(key_get KEY3 RID) -ge 0 -a $(key_get KEY3 RID) -le 32767 || ret=1
-test $(key_get KEY4 RID) -ge 0 -a $(key_get KEY4 RID) -le 32767 || ret=1
-test "$ret" -eq 0 || echo_i "failed"
-status=$((status + ret))
-
 #
 # Testing DNSSEC introduction.
 #
index bbecf098a8f2d48d6f450c0de363be7fd9d1cfe1..2ab26877f0e6b5d7575403d98a886aebdaf7d679 100644 (file)
@@ -19,3 +19,13 @@ dnssec-policy "manual-rollover" {
                zsk key-directory lifetime unlimited algorithm @DEFAULT_ALGORITHM@;
        };
 };
+
+dnssec-policy "multisigner-model2" {
+       dnskey-ttl 3600;
+       inline-signing no;
+
+       keys {
+               ksk key-directory lifetime unlimited algorithm @DEFAULT_ALGORITHM@ tag-range 32768 65535;
+               zsk key-directory lifetime unlimited algorithm @DEFAULT_ALGORITHM@ tag-range 32768 65535;
+       };
+};
index b11a77fc0600cc9031f6d6a8214ad2547fd16c8d..573865d1b9658653d372c6910ff9398110913950 100644 (file)
@@ -48,3 +48,22 @@ zone "manual-rollover.kasp" {
         file "manual-rollover.kasp.db";
         dnssec-policy "manual-rollover";
 };
+
+/* RFC 8901 Multi-signer Model 2. */
+zone "multisigner-model2.kasp" {
+        type primary;
+        file "multisigner-model2.kasp.db";
+        dnssec-policy "multisigner-model2";
+        allow-update { any; };
+};
+
+/*
+ * A zone that starts with keys that have tags that are
+ * outside of the desired multi-signer key tag range.
+ */
+zone "single-to-multisigner.kasp" {
+        type primary;
+        file "single-to-multisigner.kasp.db";
+        dnssec-policy "multisigner-model2";
+        allow-update { any; };
+};
index 59fd259aed4abf2f6de126ba53be1291279ce977..969b6a7e6c126d02466afc176d0a63917ab19cb9 100644 (file)
@@ -52,3 +52,30 @@ private_type_record $zone $DEFAULT_ALGORITHM_NUMBER "$KSK" >>"$infile"
 private_type_record $zone $DEFAULT_ALGORITHM_NUMBER "$ZSK" >>"$infile"
 cp $infile $zonefile
 $SIGNER -PS -x -o $zone -O raw -f "${zonefile}.signed" $infile >signer.out.$zone.1 2>&1
+
+# Multi-signer zones.
+setup "multisigner-model2.kasp"
+cp template.db.in "$zonefile"
+KSK=$($KEYGEN -a $DEFAULT_ALGORITHM -f KSK -L 3600 -M 32768:65535 $zone 2>keygen.out.$zone.1)
+ZSK=$($KEYGEN -a $DEFAULT_ALGORITHM -L 3600 -M 32768:65535 $zone 2>keygen.out.$zone.2)
+cat "${KSK}.key" | grep -v ";.*" >>"${zone}.db"
+cat "${ZSK}.key" | grep -v ";.*" >>"${zone}.db"
+# Import a ZSK of another provider into the DNSKEY RRset.
+ZSK1=$($KEYGEN -K ../ -a $DEFAULT_ALGORITHM -L 3600 -M 0:32767 $zone 2>keygen.out.$zone.3)
+cat "../${ZSK1}.key" | grep -v ";.*" >>"${zone}.db"
+
+# We are changing an existing single-signed zone to multi-signed
+# zone where the key tags do not match the dnssec-policy key tag range
+setup single-to-multisigner.kasp
+T="now-7d"
+S="now-8635mi" # T - 1d5m
+keytimes="-P $T -A $T"
+cdstimes="-P sync $S"
+KSK=$($KEYGEN -a $DEFAULT_ALGORITHM -M 0:32767 -L 3600 -f KSK $keytimes $cdstimes $zone 2>keygen.out.$zone.1)
+ZSK=$($KEYGEN -a $DEFAULT_ALGORITHM -M 0:32767 -L 3600 $keytimes $zone 2>keygen.out.$zone.2)
+$SETTIME -s -g $O -d $O $T -k $O $T -r $O $T "$KSK" >settime.out.$zone.1 2>&1
+$SETTIME -s -g $O -k $O $T -z $O $T "$ZSK" >settime.out.$zone.2 2>&1
+cat template.db.in "${KSK}.key" "${ZSK}.key" >"$infile"
+$SIGNER -PS -z -x -s now-2w -e now-1mi -o $zone -f "${zonefile}" $infile >signer.out.$zone.1 2>&1
+echo "Lifetime: 0" >>"${KSK}".state
+echo "Lifetime: 0" >>"${ZSK}".state
index d85ff71c26820dbf95a62a73429c7eb353802e70..a055639d36a6732f7b92b2fbd2a67c32b2679eb9 100644 (file)
@@ -25,8 +25,11 @@ pytestmark = pytest.mark.extra_artifacts(
     [
         "*.axfr*",
         "dig.out*",
+        "K*.key*",
+        "K*.private*",
         "ns*/*.db",
         "ns*/*.db.infile",
+        "ns*/*.db.jnl",
         "ns*/*.db.jbk",
         "ns*/*.db.signed",
         "ns*/*.db.signed.jnl",
@@ -220,3 +223,162 @@ def test_rollover_manual(servers):
     zsk = expected[3].key
     response = server.rndc(f"dnssec -rollover -key {zsk.tag} {zone}")
     assert "key is not actively signing" in response
+
+
+def test_rollover_multisigner(servers):
+    server = servers["ns3"]
+    policy = "multisigner-model2"
+    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 = int(config["dnskey-ttl"].total_seconds())
+    alg = os.environ["DEFAULT_ALGORITHM_NUMBER"]
+    size = os.environ["DEFAULT_BITS"]
+
+    offset = -timedelta(days=7)
+    offval = int(offset.total_seconds())
+
+    def keygen(zone):
+        keygen_command = [
+            os.environ.get("KEYGEN"),
+            "-a",
+            alg,
+            "-L",
+            "3600",
+            "-M",
+            "0:32767",
+            zone,
+        ]
+
+        return isctest.run.cmd(keygen_command, log_stdout=True).stdout.decode("utf-8")
+
+    def nsupdate(updates):
+        message = dns.update.UpdateMessage(zone)
+        for update in updates:
+            if update[0] == 0:
+                message.delete(update[1], update[2], update[3])
+            else:
+                message.add(update[1], update[2], update[3], update[4])
+
+        try:
+            response = isctest.query.udp(
+                message, server.ip, server.ports.dns, timeout=3
+            )
+            assert response.rcode() == dns.rcode.NOERROR
+        except dns.exception.Timeout:
+            isctest.log.info(f"error: update timeout for {zone}")
+
+    zone = "multisigner-model2.kasp"
+    key_properties = [
+        f"ksk unlimited {alg} {size} goal:omnipresent dnskey:rumoured krrsig:rumoured ds:hidden tag-range:32768-65535",
+        f"zsk unlimited {alg} {size} goal:omnipresent dnskey:rumoured zrrsig:rumoured tag-range:32768-65535",
+    ]
+    expected = isctest.kasp.policy_to_properties(ttl, key_properties)
+
+    newprops = [f"zsk unlimited {alg} {size} tag-range:0-32767"]
+    expected2 = isctest.kasp.policy_to_properties(ttl, newprops)
+    expected2[0].properties["private"] = False
+    expected2[0].properties["legacy"] = True
+    expected = expected + expected2
+
+    ownkeys = isctest.kasp.keydir_to_keylist(zone, server.identifier)
+    extkeys = isctest.kasp.keydir_to_keylist(zone)
+    keys = ownkeys + extkeys
+    ksks = [k for k in ownkeys if k.is_ksk()]
+    zsks = [k for k in ownkeys if not k.is_ksk()]
+    zsks = zsks + extkeys
+
+    isctest.kasp.check_zone_is_signed(server, zone)
+    isctest.kasp.check_keys(zone, keys, expected)
+    for kp in expected:
+        kp.set_expected_keytimes(config)
+    isctest.kasp.check_keytimes(keys, expected)
+    isctest.kasp.check_dnssecstatus(server, zone, keys, policy=policy)
+    isctest.kasp.check_apex(server, zone, ksks, zsks)
+    isctest.kasp.check_subdomain(server, zone, ksks, zsks)
+    isctest.kasp.check_dnssec_verify(server, zone)
+
+    # Update zone with ZSK from another provider for zone.
+    out = keygen(zone)
+    newkeys = isctest.kasp.keystr_to_keylist(out)
+    newprops = [f"zsk unlimited {alg} {size} tag-range:0-32767"]
+    expected2 = isctest.kasp.policy_to_properties(ttl, newprops)
+    expected2[0].properties["private"] = False
+    expected2[0].properties["legacy"] = True
+    expected = expected + expected2
+
+    dnskey = newkeys[0].dnskey().split()
+    rdata = " ".join(dnskey[4:])
+
+    updates = [[1, f"{dnskey[0]}", 3600, "DNSKEY", rdata]]
+    nsupdate(updates)
+
+    keys = keys + newkeys
+    zsks = zsks + newkeys
+    isctest.kasp.check_keys(zone, keys, expected)
+    isctest.kasp.check_apex(server, zone, ksks, zsks)
+    isctest.kasp.check_subdomain(server, zone, ksks, zsks)
+    isctest.kasp.check_dnssec_verify(server, zone)
+
+    # Remove ZSKs from the other providers for zone.
+    dnskey2 = extkeys[0].dnskey().split()
+    rdata2 = " ".join(dnskey2[4:])
+    updates = [
+        [0, f"{dnskey[0]}", "DNSKEY", rdata],
+        [0, f"{dnskey2[0]}", "DNSKEY", rdata2],
+    ]
+    nsupdate(updates)
+
+    expected = isctest.kasp.policy_to_properties(ttl, key_properties)
+    keys = ownkeys
+    ksks = [k for k in ownkeys if k.is_ksk()]
+    zsks = [k for k in ownkeys if not k.is_ksk()]
+    isctest.kasp.check_keys(zone, keys, expected)
+    isctest.kasp.check_apex(server, zone, ksks, zsks)
+    isctest.kasp.check_subdomain(server, zone, ksks, zsks)
+    isctest.kasp.check_dnssec_verify(server, zone)
+
+    # A zone transitioning from single-signed to multi-signed. We should have
+    # the old omnipresent keys outside of the desired key range and the new
+    # keys in the desired key range.
+    zone = "single-to-multisigner.kasp"
+    key_properties = [
+        f"ksk unlimited {alg} {size} goal:omnipresent dnskey:rumoured krrsig:rumoured ds:hidden tag-range:32768-65535",
+        f"zsk unlimited {alg} {size} goal:omnipresent dnskey:rumoured zrrsig:hidden tag-range:32768-65535",
+        f"ksk unlimited {alg} {size} goal:hidden dnskey:omnipresent krrsig:omnipresent ds:omnipresent tag-range:0-32767 offset:{offval}",
+        f"zsk unlimited {alg} {size} goal:hidden dnskey:omnipresent zrrsig:omnipresent tag-range:0-32767 offset:{offval}",
+    ]
+    expected = isctest.kasp.policy_to_properties(ttl, key_properties)
+    keys = isctest.kasp.keydir_to_keylist(zone, server.identifier)
+    ksks = [k for k in keys if k.is_ksk()]
+    zsks = [k for k in keys if not k.is_ksk()]
+
+    isctest.kasp.check_zone_is_signed(server, zone)
+    isctest.kasp.check_keys(zone, keys, expected)
+
+    for kp in expected:
+        kp.set_expected_keytimes(config)
+
+    start = expected[0].key.get_timing("Created")
+    expected[2].timing["Retired"] = start
+    expected[2].timing["Removed"] = expected[2].timing["Retired"] + Iret(
+        config, zsk=False, ksk=True
+    )
+    expected[3].timing["Retired"] = start
+    expected[3].timing["Removed"] = expected[3].timing["Retired"] + Iret(
+        config, zsk=True, ksk=False
+    )
+
+    isctest.kasp.check_keytimes(keys, expected)
+    isctest.kasp.check_dnssecstatus(server, zone, keys, policy=policy)
+    isctest.kasp.check_apex(server, zone, ksks, zsks)
+    isctest.kasp.check_subdomain(server, zone, ksks, zsks)
+    isctest.kasp.check_dnssec_verify(server, zone)