Skip to content

Commit d2cc967

Browse files
Merge pull request #325 from vladimir-v-diaz/awwad-detect_expiry_322_clean
Awwad detect expiry 322 clean
2 parents a417f67 + dd8a7eb commit d2cc967

File tree

4 files changed

+239
-42
lines changed

4 files changed

+239
-42
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ build/*
1212
.coverage
1313
.tox/*
1414
tests/htmlcov/*
15+
.DS_Store

tests/test_indefinite_freeze_attack.py

Lines changed: 162 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,15 @@
1515
than verifying text output), use pre-generated repository files, and
1616
discontinue use of the old repository tools. -vladimir.v.diaz
1717
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+
1827
<Copyright>
1928
See LICENSE for licensing information.
2029
@@ -171,21 +180,35 @@ def tearDown(self):
171180

172181

173182
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+
#
180201
# A non-TUF client (without a way to detect when metadata has expired) is
181202
# 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.
183205

184206
timestamp_path = os.path.join(self.repository_directory, 'metadata',
185207
'timestamp.json')
186208

187209
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))
189212
expires = expires.isoformat() + 'Z'
190213
timestamp_metadata['signed']['expires'] = expires
191214
tuf.formats.check_signable_object_format(timestamp_metadata)
@@ -216,47 +239,163 @@ def test_without_tuf(self):
216239
self.assertEqual(download_fileinfo, fileinfo)
217240

218241

219-
220242
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.')
224325

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'.
227349

228350
# Modify the timestamp file on the remote repository. 'timestamp.json'
229351
# must be properly updated and signed with 'repository_tool.py', otherwise
230352
# the client will reject it as invalid metadata. The resulting
231353
# 'timestamp.json' should be valid metadata, but expired (as intended).
232354
repository = repo_tool.load_repository(self.repository_directory)
233355

234-
key_file = os.path.join(self.keystore_directory, 'timestamp_key')
356+
key_file = os.path.join(self.keystore_directory, 'timestamp_key')
235357
timestamp_private = repo_tool.import_rsa_privatekey_from_file(key_file,
236358
'password')
237359

238360
repository.timestamp.load_signing_key(timestamp_private)
239361

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))
242368
repository.timestamp.expiration = datetime_object
243369
repository.write()
244370

245371
# Move the staged metadata to the "live" metadata.
246372
shutil.rmtree(os.path.join(self.repository_directory, 'metadata'))
247373
shutil.copytree(os.path.join(self.repository_directory, 'metadata.staged'),
248374
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.
254384
try:
255-
self.repository_updater.refresh()
385+
self.repository_updater.refresh() # We expect NoWorkingMirrorError.
256386

257387
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).
258393
for mirror_url, mirror_error in six.iteritems(e.mirror_errors):
259394
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.')
260399

261400

262401
if __name__ == '__main__':

tuf/__init__.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030

3131
import six
3232

33+
import logging
34+
logger = logging.getLogger('tuf.__init__')
35+
3336
# Import 'tuf.formats' if a module tries to import the
3437
# entire tuf package (i.e., from tuf import *).
3538
__all__ = ['formats']
@@ -332,13 +335,13 @@ def __init__(self, mirror_errors):
332335
def __str__(self):
333336
all_errors = 'No working mirror was found:'
334337

335-
for mirror_url, mirror_error in self.mirror_errors.iteritems():
338+
for mirror_url, mirror_error in six.iteritems(self.mirror_errors):
336339
try:
337340
# http://docs.python.org/2/library/urlparse.html#urlparse.urlparse
338341
mirror_url_tokens = six.moves.urllib.parse.urlparse(mirror_url)
339342

340343
except:
341-
logging.exception('Failed to parse mirror URL: ' + repr(mirror_url))
344+
logger.exception('Failed to parse mirror URL: ' + repr(mirror_url))
342345
mirror_netloc = mirror_url
343346

344347
else:

0 commit comments

Comments
 (0)