Skip to content

Commit e817473

Browse files
author
Jussi Kukkonen
committed
tests: Add root key rotation tests
Add one test with 1 subtests for various root key rotation situations. The test data definition format is a bit tricky but I tried to document that in the test function docstring. Signed-off-by: Jussi Kukkonen <[email protected]>
1 parent ad80bd9 commit e817473

File tree

1 file changed

+211
-0
lines changed

1 file changed

+211
-0
lines changed

tests/test_updater_key_rotations.py

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
#!/usr/bin/env python
2+
3+
# Copyright 2021, New York University and the TUF contributors
4+
# SPDX-License-Identifier: MIT OR Apache-2.0
5+
6+
"""Test ngclient Updater key rotation handling"""
7+
8+
from dataclasses import dataclass
9+
from typing import List, Optional, Type
10+
import os
11+
import sys
12+
import tempfile
13+
import unittest
14+
15+
from securesystemslib.signer import SSlibSigner
16+
17+
from tuf.api.metadata import Key
18+
from tuf.exceptions import UnsignedMetadataError
19+
from tuf.ngclient import Updater
20+
21+
from tests import utils
22+
from tests.repository_simulator import RepositorySimulator
23+
from tests.utils import run_sub_tests_with_dataset
24+
25+
26+
@dataclass
27+
class RootVersion:
28+
keys: List[int]
29+
threshold: int
30+
signatures: List[int]
31+
result: Optional[Type[Exception]] = None
32+
33+
34+
class TestUpdaterKeyRotations(unittest.TestCase):
35+
"""Test ngclient root rotation handling"""
36+
# set dump_dir to trigger repository state dumps
37+
dump_dir: Optional[str] = None
38+
39+
def setUp(self) -> None:
40+
self.sim = None
41+
self.metadata_dir = None
42+
self.subtest_count = 0
43+
# pylint: disable-next=consider-using-with
44+
self.temp_dir = tempfile.TemporaryDirectory()
45+
46+
# Pre-create a bunch of keys and signers
47+
self.keys: List[Key] = []
48+
self.signers: List[SSlibSigner] = []
49+
for _ in range(10):
50+
key, signer = RepositorySimulator.create_key()
51+
self.keys.append(key)
52+
self.signers.append(signer)
53+
54+
def tearDown(self) -> None:
55+
self.temp_dir.cleanup()
56+
57+
def setup_subtest(self) -> None:
58+
self.subtest_count += 1
59+
60+
# Setup repository for subtest: make sure no roots have been published
61+
self.sim = RepositorySimulator()
62+
self.sim.signed_roots.clear()
63+
self.sim.root.version = 0
64+
65+
if self.dump_dir is not None:
66+
# create subtest dumpdir
67+
name = f"{self.id().split('.')[-1]}-{self.subtest_count}"
68+
self.sim.dump_dir = os.path.join(self.dump_dir, name)
69+
os.mkdir(self.sim.dump_dir)
70+
71+
def _run_refresh(self) -> None:
72+
"""Create new updater, run refresh"""
73+
if self.sim.dump_dir is not None:
74+
self.sim.write()
75+
76+
# bootstrap with initial root
77+
self.metadata_dir = tempfile.mkdtemp(dir=self.temp_dir.name)
78+
with open(os.path.join(self.metadata_dir, "root.json"), "bw") as f:
79+
f.write(self.sim.signed_roots[0])
80+
81+
updater = Updater(
82+
self.metadata_dir,
83+
"https://example.com/metadata/",
84+
fetcher=self.sim,
85+
)
86+
updater.refresh()
87+
88+
root_rotation_cases = {
89+
"1-of-1 key rotation": [
90+
RootVersion([1], 1, [1]),
91+
RootVersion([2], 1, [2, 1]),
92+
RootVersion([2], 1, [2]),
93+
],
94+
"1-of-1 key rotation, unused signatures": [
95+
RootVersion([1], 1, [3, 1, 4]),
96+
RootVersion([2], 1, [3, 2, 1, 4]),
97+
RootVersion([2], 1, [3, 2, 4]),
98+
],
99+
"1-of-1 key rotation fail: not signed with old key": [
100+
RootVersion([1], 1, [1]),
101+
RootVersion([2], 1, [2, 3, 4], UnsignedMetadataError),
102+
],
103+
"1-of-1 key rotation fail: not signed with new key": [
104+
RootVersion([1], 1, [1]),
105+
RootVersion([2], 1, [1, 3, 4], UnsignedMetadataError),
106+
],
107+
"3-of-5, sign with different keycombos": [
108+
RootVersion([0, 1, 2, 3, 4], 3, [0, 2, 4]),
109+
RootVersion([0, 1, 2, 3, 4], 3, [0, 4, 1]),
110+
RootVersion([0, 1, 2, 3, 4], 3, [0, 1, 3]),
111+
RootVersion([0, 1, 2, 3, 4], 3, [0, 1, 3]),
112+
],
113+
"3-of-5, one key rotated": [
114+
RootVersion([0, 1, 2, 3, 4], 3, [0, 2, 4]),
115+
RootVersion([0, 1, 3, 4, 5], 3, [0, 4, 1]),
116+
],
117+
"3-of-5, one key rotate fails: not signed with 3 new keys": [
118+
RootVersion([0, 1, 2, 3, 4], 3, [0, 2, 4]),
119+
RootVersion([0, 1, 3, 4, 5], 3, [0, 2, 4], UnsignedMetadataError),
120+
],
121+
"3-of-5, one key rotate fails: not signed with 3 old keys": [
122+
RootVersion([0, 1, 2, 3, 4], 3, [0, 2, 4]),
123+
RootVersion([0, 1, 3, 4, 5], 3, [0, 4, 5], UnsignedMetadataError),
124+
],
125+
"3-of-5, one key rotated, with intermediate step": [
126+
RootVersion([0, 1, 2, 3, 4], 3, [0, 2, 4]),
127+
RootVersion([0, 1, 3, 4, 5], 3, [0, 2, 4, 5]),
128+
RootVersion([0, 1, 3, 4, 5], 3, [0, 4, 5]),
129+
],
130+
"3-of-5, all keys rotated, with intermediate step": [
131+
RootVersion([0, 1, 2, 3, 4], 3, [0, 2, 4]),
132+
RootVersion([5, 6, 7, 8, 9], 3, [0, 2, 4, 5, 6, 7]),
133+
RootVersion([5, 6, 7, 8, 9], 3, [5, 6, 7]),
134+
],
135+
"1-of-3 threshold increase to 2-of-3": [
136+
RootVersion([1, 2, 3], 1, [1]),
137+
RootVersion([1, 2, 3], 2, [1, 2]),
138+
],
139+
"1-of-3 threshold bump to 2-of-3 fails: new threshold not reached": [
140+
RootVersion([1, 2, 3], 1, [1]),
141+
RootVersion([1, 2, 3], 2, [2], UnsignedMetadataError),
142+
],
143+
"2-of-3 threshold decrease to 1-of-3": [
144+
RootVersion([1, 2, 3], 2, [1, 2]),
145+
RootVersion([1, 2, 3], 1, [1, 2]),
146+
RootVersion([1, 2, 3], 1, [1]),
147+
],
148+
"2-of-3 threshold decr. to 1-of-3 fails: old threshold not reached": [
149+
RootVersion([1, 2, 3], 2, [1, 2]),
150+
RootVersion([1, 2, 3], 1, [1], UnsignedMetadataError),
151+
],
152+
"1-of-2 threshold increase to 2-of-2": [
153+
RootVersion([1], 1, [1]),
154+
RootVersion([1, 2], 2, [1, 2]),
155+
],
156+
}
157+
158+
@run_sub_tests_with_dataset(root_rotation_cases)
159+
def test_root_rotation(self, root_versions: List[RootVersion]) -> None:
160+
"""Test Updater.refresh() with various sequences of root updates
161+
162+
Each RootVersion in the list describes root keys and signatures of a
163+
remote root metadata version. As an example:
164+
RootVersion([1,2,3], 2, [1,2])
165+
defines a root that contains keys 1, 2 and 3 with threshold 2. The
166+
metadata is signed with keys 1 and 2.
167+
168+
Assert that refresh() result is expected and that local root on disk is
169+
the expected one after all roots have been loaded from remote using the
170+
standard client update workflow.
171+
"""
172+
self.setup_subtest()
173+
174+
# Publish all remote root versions defined in root_versions
175+
for rootver in root_versions:
176+
# clear root keys, signers
177+
self.sim.root.roles["root"].keyids.clear()
178+
self.sim.signers["root"].clear()
179+
180+
self.sim.root.roles["root"].threshold = rootver.threshold
181+
for i in rootver.keys:
182+
self.sim.root.add_key("root", self.keys[i])
183+
for i in rootver.signatures:
184+
self.sim.add_signer("root", self.signers[i])
185+
self.sim.root.version += 1
186+
self.sim.publish_root()
187+
188+
# run client workflow, assert success/failure
189+
expected_result = root_versions[-1].result
190+
if expected_result is None:
191+
self._run_refresh()
192+
expected_local_root = self.sim.signed_roots[-1]
193+
else:
194+
# failure expected: local root should be the root before last
195+
with self.assertRaises(expected_result):
196+
self._run_refresh()
197+
expected_local_root = self.sim.signed_roots[-2]
198+
199+
# assert local root on disk is expected
200+
with open(os.path.join(self.metadata_dir, "root.json"), "rb") as f:
201+
self.assertEqual(f.read(), expected_local_root)
202+
203+
204+
if __name__ == "__main__":
205+
if "--dump" in sys.argv:
206+
TestUpdaterKeyRotations.dump_dir = tempfile.mkdtemp()
207+
print(f"Repository dumps in {TestUpdaterKeyRotations.dump_dir}")
208+
sys.argv.remove("--dump")
209+
210+
utils.configure_test_logging(sys.argv)
211+
unittest.main()

0 commit comments

Comments
 (0)