|
15 | 15 | than verifying text output), use pre-generated repository files, and
|
16 | 16 | discontinue use of the old repository tools. -vladimir.v.diaz
|
17 | 17 |
|
| 18 | + March 9, 2016. |
| 19 | + Additional test added relating to issue: |
| 20 | + https://github.com/theupdateframework/tuf/issues/322 |
| 21 | + If a metadata file is not updated (no indication of a new version |
| 22 | + available), the expiration of the pre-existing, locally trusted metadata |
| 23 | + must still be detected. This additional test complains if such does not |
| 24 | + occur, and accompanies code in tuf.client.updater:refresh() to detect it. |
| 25 | + -sebastien.awwad |
| 26 | +
|
18 | 27 | <Copyright>
|
19 | 28 | See LICENSE for licensing information.
|
20 | 29 |
|
@@ -171,21 +180,35 @@ def tearDown(self):
|
171 | 180 |
|
172 | 181 |
|
173 | 182 | def test_without_tuf(self):
|
174 |
| - # Scenario: |
175 |
| - # 'timestamp.json' specifies the latest version of the repository files. |
176 |
| - # A client should only accept the same version of this file up to a certain |
177 |
| - # point, or else it cannot detect that new files are available for download. |
178 |
| - # Modify the repository's timestamp.json' so that it expires soon, copy it |
179 |
| - # over the to client, and attempt to re-fetch the same expired version. |
| 183 | + # Without TUF, Test 1 and Test 2 are functionally equivalent, so we skip |
| 184 | + # Test 1 and only perform Test 2. |
| 185 | + # |
| 186 | + # Test 1: If we find that the timestamp acquired from a mirror indicates |
| 187 | + # that there is no new snapshot file, and our current snapshot |
| 188 | + # file is expired, is it recognized as such? |
| 189 | + # Test 2: If an expired timestamp is downloaded, is it recognized as such? |
| 190 | + |
| 191 | + |
| 192 | + # Test 2 Begin: |
| 193 | + # |
| 194 | + # 'timestamp.json' specifies the latest version of the repository files. A |
| 195 | + # client should only accept the same version of this file up to a certain |
| 196 | + # point, or else it cannot detect that new files are available for |
| 197 | + # download. Modify the repository's timestamp.json' so that it expires |
| 198 | + # soon, copy it over to the client, and attempt to re-fetch the same |
| 199 | + # expired version. |
| 200 | + # |
180 | 201 | # A non-TUF client (without a way to detect when metadata has expired) is
|
181 | 202 | # expected to download the same version, and thus the same outdated files.
|
182 |
| - # Verify that the same file size and hash of 'timestamp.json' is downloaded. |
| 203 | + # Verify that the downloaded 'timestamp.json' contains the same file size |
| 204 | + # and hash as the one available locally. |
183 | 205 |
|
184 | 206 | timestamp_path = os.path.join(self.repository_directory, 'metadata',
|
185 | 207 | 'timestamp.json')
|
186 | 208 |
|
187 | 209 | timestamp_metadata = tuf.util.load_json_file(timestamp_path)
|
188 |
| - expires = tuf.formats.unix_timestamp_to_datetime(int(time.time() - 10)) |
| 210 | + expiry_time = time.time() - 10 |
| 211 | + expires = tuf.formats.unix_timestamp_to_datetime(int(expiry_time)) |
189 | 212 | expires = expires.isoformat() + 'Z'
|
190 | 213 | timestamp_metadata['signed']['expires'] = expires
|
191 | 214 | tuf.formats.check_signable_object_format(timestamp_metadata)
|
@@ -216,47 +239,163 @@ def test_without_tuf(self):
|
216 | 239 | self.assertEqual(download_fileinfo, fileinfo)
|
217 | 240 |
|
218 | 241 |
|
219 |
| - |
220 | 242 | def test_with_tuf(self):
|
221 |
| - # The same scenario outlined in test_without_tuf() is followed here, except |
222 |
| - # with a TUF client. The TUF client performs a refresh of top-level |
223 |
| - # metadata, which also includes 'timestamp.json'. |
| 243 | + # Two tests are conducted here. |
| 244 | + # |
| 245 | + # Test 1: If we find that the timestamp acquired from a mirror indicates |
| 246 | + # that there is no new snapshot file, and our current snapshot |
| 247 | + # file is expired, is it recognized as such? |
| 248 | + # Test 2: If an expired timestamp is downloaded, is it recognized as such? |
| 249 | + |
| 250 | + |
| 251 | + # Test 1 Begin: |
| 252 | + # |
| 253 | + # Addresses this issue: https://github.com/theupdateframework/tuf/issues/322 |
| 254 | + # |
| 255 | + # If time has passed and our snapshot or targets role is expired, and |
| 256 | + # the mirror whose timestamp we fetched doesn't indicate the existence of a |
| 257 | + # new snapshot version, we still need to check that it's expired and notify |
| 258 | + # the software update system / application / user. This test creates that |
| 259 | + # scenario. The correct behavior is to raise an exception. |
| 260 | + # |
| 261 | + # Background: Expiration checks (updater._ensure_not_expired) were |
| 262 | + # previously conducted when the metadata file was downloaded. If no new |
| 263 | + # metadata file was downloaded, no expiry check would occur. In particular, |
| 264 | + # while root was checked for expiration at the beginning of each |
| 265 | + # updater.refresh() cycle, and timestamp was always checked because it was |
| 266 | + # always fetched, snapshot and targets were never checked if the user did |
| 267 | + # not receive evidence that they had changed. This bug allowed a class of |
| 268 | + # freeze attacks. |
| 269 | + # That bug was fixed and this test tests that fix going forward. |
| 270 | + |
| 271 | + # Modify the timestamp file on the remote repository. 'timestamp.json' |
| 272 | + # must be properly updated and signed with 'repository_tool.py', otherwise |
| 273 | + # the client will reject it as invalid metadata. |
| 274 | + |
| 275 | + # Load the repository |
| 276 | + repository = repo_tool.load_repository(self.repository_directory) |
| 277 | + |
| 278 | + # Load the timestamp and snapshot keys, since we will be signing a new |
| 279 | + # timestamp and a new snapshot file. |
| 280 | + key_file = os.path.join(self.keystore_directory, 'timestamp_key') |
| 281 | + timestamp_private = repo_tool.import_rsa_privatekey_from_file(key_file, |
| 282 | + 'password') |
| 283 | + repository.timestamp.load_signing_key(timestamp_private) |
| 284 | + key_file = os.path.join(self.keystore_directory, 'snapshot_key') |
| 285 | + snapshot_private = repo_tool.import_rsa_privatekey_from_file(key_file, |
| 286 | + 'password') |
| 287 | + repository.snapshot.load_signing_key(snapshot_private) |
| 288 | + |
| 289 | + # Expire snapshot in 8s. This should be far enough into the future that we |
| 290 | + # haven't reached it before the first refresh validates timestamp expiry. |
| 291 | + # We want a successful refresh before expiry, then a second refresh after |
| 292 | + # expiry (which we then expect to raise an exception due to expired |
| 293 | + # metadata). |
| 294 | + expiry_time = time.time() + 8 |
| 295 | + datetime_object = tuf.formats.unix_timestamp_to_datetime(int(expiry_time)) |
| 296 | + |
| 297 | + repository.snapshot.expiration = datetime_object |
| 298 | + |
| 299 | + # Now write to the repository. |
| 300 | + repository.write() |
| 301 | + |
| 302 | + # And move the staged metadata to the "live" metadata. |
| 303 | + shutil.rmtree(os.path.join(self.repository_directory, 'metadata')) |
| 304 | + shutil.copytree(os.path.join(self.repository_directory, 'metadata.staged'), |
| 305 | + os.path.join(self.repository_directory, 'metadata')) |
| 306 | + |
| 307 | + # Refresh metadata on the client. For this refresh, all data is not expired. |
| 308 | + logger.info('Test: Refreshing #1 - Initial metadata refresh occurring.') |
| 309 | + self.repository_updater.refresh() |
| 310 | + logger.info('Test: Refreshed #1 - Initial metadata refresh completed ' |
| 311 | + 'successfully. Now sleeping until snapshot metadata expires.') |
| 312 | + |
| 313 | + # Sleep until expiry_time ('repository.snapshot.expiration') |
| 314 | + time.sleep(max(0, expiry_time - time.time())) |
| 315 | + |
| 316 | + logger.info('Test: Refreshing #2 - Now trying to refresh again after local' |
| 317 | + ' snapshot expiry.') |
| 318 | + try: |
| 319 | + self.repository_updater.refresh() # We expect this to fail! |
| 320 | + |
| 321 | + except tuf.ExpiredMetadataError: |
| 322 | + logger.info('Test: Refresh #2 - failed as expected. Expired local' |
| 323 | + ' snapshot case generated a tuf.ExpiredMetadataError' |
| 324 | + ' exception as expected. Test pass.') |
224 | 325 |
|
225 |
| - timestamp_path = os.path.join(self.repository_directory, 'metadata', |
226 |
| - 'timestamp.json') |
| 326 | + # I think that I only expect tuf.ExpiredMetadata error here. A |
| 327 | + # NoWorkingMirrorError indicates something else in this case - unavailable |
| 328 | + # repo, for example. |
| 329 | + else: |
| 330 | + self.fail('TUF failed to detect expired stale snapshot metadata. Freeze' |
| 331 | + ' attack successful.') |
| 332 | + |
| 333 | + |
| 334 | + |
| 335 | + |
| 336 | + # Test 2 Begin: |
| 337 | + # |
| 338 | + # 'timestamp.json' specifies the latest version of the repository files. |
| 339 | + # A client should only accept the same version of this file up to a certain |
| 340 | + # point, or else it cannot detect that new files are available for download. |
| 341 | + # Modify the repository's 'timestamp.json' so that it is about to expire, |
| 342 | + # copy it over the to client, wait a moment until it expires, and attempt to |
| 343 | + # re-fetch the same expired version. |
| 344 | + |
| 345 | + # The same scenario as in test_without_tuf() is followed here, except with |
| 346 | + # a TUF client. The TUF client performs a refresh of top-level metadata, |
| 347 | + # which includes 'timestamp.json', and should detect a freeze attack if |
| 348 | + # the repository serves an outdated 'timestamp.json'. |
227 | 349 |
|
228 | 350 | # Modify the timestamp file on the remote repository. 'timestamp.json'
|
229 | 351 | # must be properly updated and signed with 'repository_tool.py', otherwise
|
230 | 352 | # the client will reject it as invalid metadata. The resulting
|
231 | 353 | # 'timestamp.json' should be valid metadata, but expired (as intended).
|
232 | 354 | repository = repo_tool.load_repository(self.repository_directory)
|
233 | 355 |
|
234 |
| - key_file = os.path.join(self.keystore_directory, 'timestamp_key') |
| 356 | + key_file = os.path.join(self.keystore_directory, 'timestamp_key') |
235 | 357 | timestamp_private = repo_tool.import_rsa_privatekey_from_file(key_file,
|
236 | 358 | 'password')
|
237 | 359 |
|
238 | 360 | repository.timestamp.load_signing_key(timestamp_private)
|
239 | 361 |
|
240 |
| - # expire in 1 second. |
241 |
| - datetime_object = tuf.formats.unix_timestamp_to_datetime(int(time.time() + 1)) |
| 362 | + # Set timestamp metadata to expire soon. |
| 363 | + # We cannot set the timestamp expiration with |
| 364 | + # 'repository.timestamp.expiration = ...' with already-expired timestamp |
| 365 | + # metadata because of consistency checks that occur during that assignment. |
| 366 | + expiry_time = time.time() + 1 |
| 367 | + datetime_object = tuf.formats.unix_timestamp_to_datetime(int(expiry_time)) |
242 | 368 | repository.timestamp.expiration = datetime_object
|
243 | 369 | repository.write()
|
244 | 370 |
|
245 | 371 | # Move the staged metadata to the "live" metadata.
|
246 | 372 | shutil.rmtree(os.path.join(self.repository_directory, 'metadata'))
|
247 | 373 | shutil.copytree(os.path.join(self.repository_directory, 'metadata.staged'),
|
248 | 374 | os.path.join(self.repository_directory, 'metadata'))
|
249 |
| - |
250 |
| - # Verify that the TUF client detects outdated metadata and refuses to |
251 |
| - # continue the update process. Sleep for at least 2 seconds to ensure |
252 |
| - # 'repository.timestamp.expiration' is reached. |
253 |
| - time.sleep(2) |
| 375 | + |
| 376 | + # Wait just long enough for the timestamp metadata (which is now both on |
| 377 | + # the repository and on the client) to expire. |
| 378 | + time.sleep(max(0, expiry_time - time.time())) |
| 379 | + |
| 380 | + # Try to refresh top-level metadata on the client. Since we're already past |
| 381 | + # 'repository.timestamp.expiration', the TUF client is expected to detect |
| 382 | + # that timestamp metadata is outdated and refuse to continue the update |
| 383 | + # process. |
254 | 384 | try:
|
255 |
| - self.repository_updater.refresh() |
| 385 | + self.repository_updater.refresh() # We expect NoWorkingMirrorError. |
256 | 386 |
|
257 | 387 | except tuf.NoWorkingMirrorError as e:
|
| 388 | + # NoWorkingMirrorError indicates that we did not find valid, unexpired |
| 389 | + # metadata at any mirror. That exception class preserves the errors from |
| 390 | + # each mirror. We now assert that for each mirror, the particular error |
| 391 | + # detected was that metadata was expired (the timestamp we manually |
| 392 | + # expired). |
258 | 393 | for mirror_url, mirror_error in six.iteritems(e.mirror_errors):
|
259 | 394 | self.assertTrue(isinstance(mirror_error, tuf.ExpiredMetadataError))
|
| 395 | + |
| 396 | + else: |
| 397 | + self.fail('TUF failed to detect expired, stale timestamp metadata.' |
| 398 | + ' Freeze attack successful.') |
260 | 399 |
|
261 | 400 |
|
262 | 401 | if __name__ == '__main__':
|
|
0 commit comments