Skip to content

Commit 942e6d2

Browse files
author
Jussi Kukkonen
authored
Merge pull request #1691 from MVrachev/key-rotations
Tests: add tests for non-root key rotations
2 parents 9ff5bfc + 3def667 commit 942e6d2

File tree

2 files changed

+134
-60
lines changed

2 files changed

+134
-60
lines changed

tests/test_updater_key_rotations.py

Lines changed: 132 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,20 @@
1010
import tempfile
1111
import unittest
1212
from dataclasses import dataclass
13-
from typing import List, Optional, Type
13+
from typing import Dict, List, Optional, Type
1414

1515
from securesystemslib.signer import SSlibSigner
1616

1717
from tests import utils
1818
from tests.repository_simulator import RepositorySimulator
1919
from tests.utils import run_sub_tests_with_dataset
20-
from tuf.api.metadata import Key, Root
20+
from tuf.api.metadata import Key, Metadata, Root
2121
from tuf.exceptions import UnsignedMetadataError
2222
from tuf.ngclient import Updater
2323

2424

2525
@dataclass
26-
class RootVersion:
26+
class MdVersion:
2727
keys: List[int]
2828
threshold: int
2929
sigs: List[int]
@@ -36,35 +36,34 @@ class TestUpdaterKeyRotations(unittest.TestCase):
3636
# set dump_dir to trigger repository state dumps
3737
dump_dir: Optional[str] = None
3838

39-
def setUp(self) -> None:
40-
self.sim: RepositorySimulator
41-
self.metadata_dir: str
42-
self.subtest_count = 0
39+
@classmethod
40+
def setUpClass(cls) -> None:
41+
cls.sim: RepositorySimulator
42+
cls.metadata_dir: str
4343
# pylint: disable-next=consider-using-with
44-
self.temp_dir = tempfile.TemporaryDirectory()
44+
cls.temp_dir = tempfile.TemporaryDirectory()
4545

4646
# Pre-create a bunch of keys and signers
47-
self.keys: List[Key] = []
48-
self.signers: List[SSlibSigner] = []
47+
cls.keys: List[Key] = []
48+
cls.signers: List[SSlibSigner] = []
4949
for _ in range(10):
5050
key, signer = RepositorySimulator.create_key()
51-
self.keys.append(key)
52-
self.signers.append(signer)
51+
cls.keys.append(key)
52+
cls.signers.append(signer)
5353

54-
def tearDown(self) -> None:
55-
self.temp_dir.cleanup()
54+
@classmethod
55+
def tearDownClass(cls) -> None:
56+
cls.temp_dir.cleanup()
5657

5758
def setup_subtest(self) -> None:
58-
self.subtest_count += 1
59-
6059
# Setup repository for subtest: make sure no roots have been published
6160
self.sim = RepositorySimulator()
6261
self.sim.signed_roots.clear()
6362
self.sim.root.version = 0
6463

6564
if self.dump_dir is not None:
6665
# create subtest dumpdir
67-
name = f"{self.id().split('.')[-1]}-{self.subtest_count}"
66+
name = f"{self.id().split('.')[-1]}-{self.case_name}"
6867
self.sim.dump_dir = os.path.join(self.dump_dir, name)
6968
os.mkdir(self.sim.dump_dir)
7069

@@ -88,82 +87,82 @@ def _run_refresh(self) -> None:
8887
# fmt: off
8988
root_rotation_cases = {
9089
"1-of-1 key rotation": [
91-
RootVersion(keys=[1], threshold=1, sigs=[1]),
92-
RootVersion(keys=[2], threshold=1, sigs=[2, 1]),
93-
RootVersion(keys=[2], threshold=1, sigs=[2]),
90+
MdVersion(keys=[1], threshold=1, sigs=[1]),
91+
MdVersion(keys=[2], threshold=1, sigs=[2, 1]),
92+
MdVersion(keys=[2], threshold=1, sigs=[2]),
9493
],
9594
"1-of-1 key rotation, unused signatures": [
96-
RootVersion(keys=[1], threshold=1, sigs=[3, 1, 4]),
97-
RootVersion(keys=[2], threshold=1, sigs=[3, 2, 1, 4]),
98-
RootVersion(keys=[2], threshold=1, sigs=[3, 2, 4]),
95+
MdVersion(keys=[1], threshold=1, sigs=[3, 1, 4]),
96+
MdVersion(keys=[2], threshold=1, sigs=[3, 2, 1, 4]),
97+
MdVersion(keys=[2], threshold=1, sigs=[3, 2, 4]),
9998
],
10099
"1-of-1 key rotation fail: not signed with old key": [
101-
RootVersion(keys=[1], threshold=1, sigs=[1]),
102-
RootVersion(keys=[2], threshold=1, sigs=[2, 3, 4], res=UnsignedMetadataError),
100+
MdVersion(keys=[1], threshold=1, sigs=[1]),
101+
MdVersion(keys=[2], threshold=1, sigs=[2, 3, 4], res=UnsignedMetadataError),
103102
],
104103
"1-of-1 key rotation fail: not signed with new key": [
105-
RootVersion(keys=[1], threshold=1, sigs=[1]),
106-
RootVersion(keys=[2], threshold=1, sigs=[1, 3, 4], res=UnsignedMetadataError),
104+
MdVersion(keys=[1], threshold=1, sigs=[1]),
105+
MdVersion(keys=[2], threshold=1, sigs=[1, 3, 4], res=UnsignedMetadataError),
107106
],
108107
"3-of-5, sign with different keycombos": [
109-
RootVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 2, 4]),
110-
RootVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 4, 1]),
111-
RootVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 1, 3]),
112-
RootVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 1, 3]),
108+
MdVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 2, 4]),
109+
MdVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 4, 1]),
110+
MdVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 1, 3]),
111+
MdVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 1, 3]),
113112
],
114113
"3-of-5, one key rotated": [
115-
RootVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 2, 4]),
116-
RootVersion(keys=[0, 1, 3, 4, 5], threshold=3, sigs=[0, 4, 1]),
114+
MdVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 2, 4]),
115+
MdVersion(keys=[0, 1, 3, 4, 5], threshold=3, sigs=[0, 4, 1]),
117116
],
118117
"3-of-5, one key rotate fails: not signed with 3 new keys": [
119-
RootVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 2, 4]),
120-
RootVersion(keys=[0, 1, 3, 4, 5], threshold=3, sigs=[0, 2, 4], res=UnsignedMetadataError),
118+
MdVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 2, 4]),
119+
MdVersion(keys=[0, 1, 3, 4, 5], threshold=3, sigs=[0, 2, 4], res=UnsignedMetadataError),
121120
],
122121
"3-of-5, one key rotate fails: not signed with 3 old keys": [
123-
RootVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 2, 4]),
124-
RootVersion(keys=[0, 1, 3, 4, 5], threshold=3, sigs=[0, 4, 5], res=UnsignedMetadataError),
122+
MdVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 2, 4]),
123+
MdVersion(keys=[0, 1, 3, 4, 5], threshold=3, sigs=[0, 4, 5], res=UnsignedMetadataError),
125124
],
126125
"3-of-5, one key rotated, with intermediate step": [
127-
RootVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 2, 4]),
128-
RootVersion(keys=[0, 1, 3, 4, 5], threshold=3, sigs=[0, 2, 4, 5]),
129-
RootVersion(keys=[0, 1, 3, 4, 5], threshold=3, sigs=[0, 4, 5]),
126+
MdVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 2, 4]),
127+
MdVersion(keys=[0, 1, 3, 4, 5], threshold=3, sigs=[0, 2, 4, 5]),
128+
MdVersion(keys=[0, 1, 3, 4, 5], threshold=3, sigs=[0, 4, 5]),
130129
],
131130
"3-of-5, all keys rotated, with intermediate step": [
132-
RootVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 2, 4]),
133-
RootVersion(keys=[5, 6, 7, 8, 9], threshold=3, sigs=[0, 2, 4, 5, 6, 7]),
134-
RootVersion(keys=[5, 6, 7, 8, 9], threshold=3, sigs=[5, 6, 7]),
131+
MdVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 2, 4]),
132+
MdVersion(keys=[5, 6, 7, 8, 9], threshold=3, sigs=[0, 2, 4, 5, 6, 7]),
133+
MdVersion(keys=[5, 6, 7, 8, 9], threshold=3, sigs=[5, 6, 7]),
135134
],
136135
"1-of-3 threshold increase to 2-of-3": [
137-
RootVersion(keys=[1, 2, 3], threshold=1, sigs=[1]),
138-
RootVersion(keys=[1, 2, 3], threshold=2, sigs=[1, 2]),
136+
MdVersion(keys=[1, 2, 3], threshold=1, sigs=[1]),
137+
MdVersion(keys=[1, 2, 3], threshold=2, sigs=[1, 2]),
139138
],
140139
"1-of-3 threshold bump to 2-of-3 fails: new threshold not reached": [
141-
RootVersion(keys=[1, 2, 3], threshold=1, sigs=[1]),
142-
RootVersion(keys=[1, 2, 3], threshold=2, sigs=[2], res=UnsignedMetadataError),
140+
MdVersion(keys=[1, 2, 3], threshold=1, sigs=[1]),
141+
MdVersion(keys=[1, 2, 3], threshold=2, sigs=[2], res=UnsignedMetadataError),
143142
],
144143
"2-of-3 threshold decrease to 1-of-3": [
145-
RootVersion(keys=[1, 2, 3], threshold=2, sigs=[1, 2]),
146-
RootVersion(keys=[1, 2, 3], threshold=1, sigs=[1, 2]),
147-
RootVersion(keys=[1, 2, 3], threshold=1, sigs=[1]),
144+
MdVersion(keys=[1, 2, 3], threshold=2, sigs=[1, 2]),
145+
MdVersion(keys=[1, 2, 3], threshold=1, sigs=[1, 2]),
146+
MdVersion(keys=[1, 2, 3], threshold=1, sigs=[1]),
148147
],
149148
"2-of-3 threshold decr. to 1-of-3 fails: old threshold not reached": [
150-
RootVersion(keys=[1, 2, 3], threshold=2, sigs=[1, 2]),
151-
RootVersion(keys=[1, 2, 3], threshold=1, sigs=[1], res=UnsignedMetadataError),
149+
MdVersion(keys=[1, 2, 3], threshold=2, sigs=[1, 2]),
150+
MdVersion(keys=[1, 2, 3], threshold=1, sigs=[1], res=UnsignedMetadataError),
152151
],
153152
"1-of-2 threshold increase to 2-of-2": [
154-
RootVersion(keys=[1], threshold=1, sigs=[1]),
155-
RootVersion(keys=[1, 2], threshold=2, sigs=[1, 2]),
153+
MdVersion(keys=[1], threshold=1, sigs=[1]),
154+
MdVersion(keys=[1, 2], threshold=2, sigs=[1, 2]),
156155
],
157156
}
158157
# fmt: on
159158

160159
@run_sub_tests_with_dataset(root_rotation_cases)
161-
def test_root_rotation(self, root_versions: List[RootVersion]) -> None:
160+
def test_root_rotation(self, root_versions: List[MdVersion]) -> None:
162161
"""Test Updater.refresh() with various sequences of root updates
163162
164-
Each RootVersion in the list describes root keys and signatures of a
163+
Each MdVersion in the list describes root keys and signatures of a
165164
remote root metadata version. As an example:
166-
RootVersion([1,2,3], 2, [1,2])
165+
MdVersion([1,2,3], 2, [1,2])
167166
defines a root that contains keys 1, 2 and 3 with threshold 2. The
168167
metadata is signed with keys 1 and 2.
169168
@@ -188,20 +187,93 @@ def test_root_rotation(self, root_versions: List[RootVersion]) -> None:
188187
self.sim.publish_root()
189188

190189
# run client workflow, assert success/failure
191-
expected_result = root_versions[-1].res
192-
if expected_result is None:
190+
expected_error = root_versions[-1].res
191+
if expected_error is None:
193192
self._run_refresh()
194193
expected_local_root = self.sim.signed_roots[-1]
195194
else:
196195
# failure expected: local root should be the root before last
197-
with self.assertRaises(expected_result):
196+
with self.assertRaises(expected_error):
198197
self._run_refresh()
199198
expected_local_root = self.sim.signed_roots[-2]
200199

201200
# assert local root on disk is expected
202201
with open(os.path.join(self.metadata_dir, "root.json"), "rb") as f:
203202
self.assertEqual(f.read(), expected_local_root)
204203

204+
# fmt: off
205+
non_root_rotation_cases: Dict[str, MdVersion] = {
206+
"1-of-1 key rotation":
207+
MdVersion(keys=[2], threshold=1, sigs=[2]),
208+
"1-of-1 key rotation, unused signatures":
209+
MdVersion(keys=[1], threshold=1, sigs=[3, 1, 4]),
210+
"1-of-1 key rotation fail: not signed with new key":
211+
MdVersion(keys=[2], threshold=1, sigs=[1, 3, 4], res=UnsignedMetadataError),
212+
"3-of-5, one key signature wrong: not signed with 3 expected keys":
213+
MdVersion(keys=[0, 1, 3, 4, 5], threshold=3, sigs=[0, 2, 4], res=UnsignedMetadataError),
214+
"2-of-5, one key signature mising: threshold not reached":
215+
MdVersion(keys=[0, 1, 3, 4, 5], threshold=3, sigs=[0, 4], res=UnsignedMetadataError),
216+
"3-of-5, sign first combo":
217+
MdVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 2, 4]),
218+
"3-of-5, sign second combo":
219+
MdVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 4, 1]),
220+
"3-of-5, sign third combo":
221+
MdVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[0, 1, 3]),
222+
"3-of-5, sign fourth combo":
223+
MdVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[1, 2, 3]),
224+
"3-of-5, sign fifth combo":
225+
MdVersion(keys=[0, 1, 2, 3, 4], threshold=3, sigs=[2, 3, 4]),
226+
}
227+
# fmt: on
228+
229+
@run_sub_tests_with_dataset(non_root_rotation_cases)
230+
def test_non_root_rotations(self, md_version: MdVersion) -> None:
231+
"""Test Updater.refresh() with various sequences of metadata updates
232+
233+
Each MdVersion in the list describes metadata keys and signatures
234+
of a remote metadata version. As an example:
235+
MdVersion([1,2,3], 2, [1,2])
236+
defines a metadata that contains keys 1, 2 and 3 with threshold 2. The
237+
metadata is signed with keys 1 and 2.
238+
239+
Assert that refresh() result is expected and that local metadata on disk
240+
is the expected one after all roots have been loaded from remote using
241+
the standard client update workflow.
242+
"""
243+
self.setup_subtest()
244+
roles = ["timestamp", "snapshot", "targets"]
245+
for role in roles:
246+
247+
# clear role keys, signers
248+
self.sim.root.roles[role].keyids.clear()
249+
self.sim.signers[role].clear()
250+
251+
self.sim.root.roles[role].threshold = md_version.threshold
252+
for i in md_version.keys:
253+
self.sim.root.add_key(role, self.keys[i])
254+
255+
for i in md_version.sigs:
256+
self.sim.add_signer(role, self.signers[i])
257+
258+
self.sim.root.version += 1
259+
self.sim.publish_root()
260+
261+
# run client workflow, assert success/failure
262+
expected_error = md_version.res
263+
if expected_error is None:
264+
self._run_refresh()
265+
266+
# Call fetch_metadata to sign metadata with new keys
267+
expected_local_md: Metadata = self.sim._fetch_metadata(role)
268+
# assert local metadata role is on disk as expected
269+
md_path = os.path.join(self.metadata_dir, f"{role}.json")
270+
with open(md_path, "rb") as f:
271+
data = f.read()
272+
self.assertEqual(data, expected_local_md)
273+
else:
274+
# failure expected
275+
with self.assertRaises(expected_error):
276+
self._run_refresh()
205277

206278
if __name__ == "__main__":
207279
if "--dump" in sys.argv:

tests/utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ def real_decorator(
6060
def wrapper(test_cls: unittest.TestCase) -> None:
6161
for case, data in dataset.items():
6262
with test_cls.subTest(case=case):
63+
# Save case name for future reference
64+
test_cls.case_name = case.replace(" ", "_")
6365
function(test_cls, data)
6466

6567
return wrapper

0 commit comments

Comments
 (0)