Skip to content

Commit 39ed706

Browse files
author
Jussi Kukkonen
authored
Merge pull request #1437 from sechkova/hash-verification
Add hash and length verification to MetaFile and TargetFile
2 parents 51c26b7 + dcdd332 commit 39ed706

File tree

3 files changed

+176
-16
lines changed

3 files changed

+176
-16
lines changed

tests/test_api.py

+64-4
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020

2121
from tests import utils
2222

23-
import tuf.exceptions
23+
from tuf import exceptions
2424
from tuf.api.metadata import (
2525
Metadata,
2626
Root,
@@ -178,7 +178,7 @@ def test_sign_verify(self):
178178
self.assertEqual(len(metadata_obj.signatures), 1)
179179
# ... which is valid for the correct key.
180180
targets_key.verify_signature(metadata_obj)
181-
with self.assertRaises(tuf.exceptions.UnsignedMetadataError):
181+
with self.assertRaises(exceptions.UnsignedMetadataError):
182182
snapshot_key.verify_signature(metadata_obj)
183183

184184
sslib_signer = SSlibSigner(self.keystore['snapshot'])
@@ -197,7 +197,7 @@ def test_sign_verify(self):
197197
self.assertEqual(len(metadata_obj.signatures), 1)
198198
# ... valid for that key.
199199
timestamp_key.verify_signature(metadata_obj)
200-
with self.assertRaises(tuf.exceptions.UnsignedMetadataError):
200+
with self.assertRaises(exceptions.UnsignedMetadataError):
201201
targets_key.verify_signature(metadata_obj)
202202

203203

@@ -286,7 +286,6 @@ def test_targetfile_class(self):
286286
targetfile_obj = TargetFile.from_dict(copy.copy(data))
287287
self.assertEqual(targetfile_obj.to_dict(), data)
288288

289-
290289
def test_metadata_snapshot(self):
291290
snapshot_path = os.path.join(
292291
self.repo_dir, 'metadata', 'snapshot.json')
@@ -358,6 +357,7 @@ def test_metadata_timestamp(self):
358357
timestamp_test = Timestamp.from_dict(test_dict)
359358
self.assertEqual(timestamp_dict['signed'], timestamp_test.to_dict())
360359

360+
361361
def test_key_class(self):
362362
keys = {
363363
"59a4df8af818e9ed7abe0764c0b47b4240952aa0d179b5b78346c470ac30278d":{
@@ -644,6 +644,66 @@ def test_support_for_unrecognized_fields(self):
644644
metadata_obj.signed.to_dict(), metadata_obj2.signed.to_dict()
645645
)
646646

647+
def test_length_and_hash_validation(self):
648+
649+
# Test metadata files' hash and length verification.
650+
# Use timestamp to get a MetaFile object and snapshot
651+
# for untrusted metadata file to verify.
652+
timestamp_path = os.path.join(
653+
self.repo_dir, 'metadata', 'timestamp.json')
654+
timestamp = Metadata.from_file(timestamp_path)
655+
snapshot_metafile = timestamp.signed.meta["snapshot.json"]
656+
657+
snapshot_path = os.path.join(
658+
self.repo_dir, 'metadata', 'snapshot.json')
659+
660+
with open(snapshot_path, "rb") as file:
661+
# test with data as a file object
662+
snapshot_metafile.verify_length_and_hashes(file)
663+
file.seek(0)
664+
data = file.read()
665+
# test with data as bytes
666+
snapshot_metafile.verify_length_and_hashes(data)
667+
668+
# test exceptions
669+
expected_length = snapshot_metafile.length
670+
snapshot_metafile.length = 2345
671+
self.assertRaises(exceptions.LengthOrHashMismatchError,
672+
snapshot_metafile.verify_length_and_hashes, data)
673+
674+
snapshot_metafile.length = expected_length
675+
snapshot_metafile.hashes = {'sha256': 'incorrecthash'}
676+
self.assertRaises(exceptions.LengthOrHashMismatchError,
677+
snapshot_metafile.verify_length_and_hashes, data)
678+
679+
# test optional length and hashes
680+
snapshot_metafile.length = None
681+
snapshot_metafile.hashes = None
682+
snapshot_metafile.verify_length_and_hashes(data)
683+
684+
685+
# Test target files' hash and length verification
686+
targets_path = os.path.join(
687+
self.repo_dir, 'metadata', 'targets.json')
688+
targets = Metadata.from_file(targets_path)
689+
file1_targetfile = targets.signed.targets['file1.txt']
690+
filepath = os.path.join(
691+
self.repo_dir, 'targets', 'file1.txt')
692+
693+
with open(filepath, "rb") as file1:
694+
file1_targetfile.verify_length_and_hashes(file1)
695+
696+
# test exceptions
697+
expected_length = file1_targetfile.length
698+
file1_targetfile.length = 2345
699+
self.assertRaises(exceptions.LengthOrHashMismatchError,
700+
file1_targetfile.verify_length_and_hashes, file1)
701+
702+
file1_targetfile.length = expected_length
703+
file1_targetfile.hashes = {'sha256': 'incorrecthash'}
704+
self.assertRaises(exceptions.LengthOrHashMismatchError,
705+
file1_targetfile.verify_length_and_hashes, file1)
706+
647707

648708
# Run unit test.
649709
if __name__ == '__main__':

tuf/api/metadata.py

+110-9
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,24 @@
1616
1717
"""
1818
import abc
19+
import io
1920
import tempfile
2021
from collections import OrderedDict
2122
from datetime import datetime, timedelta
22-
from typing import Any, ClassVar, Dict, List, Mapping, Optional, Tuple, Type
23+
from typing import (
24+
Any,
25+
BinaryIO,
26+
ClassVar,
27+
Dict,
28+
List,
29+
Mapping,
30+
Optional,
31+
Tuple,
32+
Type,
33+
Union,
34+
)
2335

36+
from securesystemslib import hash as sslib_hash
2437
from securesystemslib import keys as sslib_keys
2538
from securesystemslib.signer import Signature, Signer
2639
from securesystemslib.storage import FilesystemBackend, StorageBackendInterface
@@ -644,7 +657,53 @@ def remove_key(self, role: str, keyid: str) -> None:
644657
del self.keys[keyid]
645658

646659

647-
class MetaFile:
660+
class BaseFile:
661+
"""A base class of MetaFile and TargetFile.
662+
663+
Encapsulates common static methods for length and hash verification.
664+
"""
665+
666+
@staticmethod
667+
def _verify_hashes(
668+
data: Union[bytes, BinaryIO], expected_hashes: Dict[str, str]
669+
) -> None:
670+
"""Verifies that the hash of 'data' matches 'expected_hashes'"""
671+
is_bytes = isinstance(data, bytes)
672+
for algo, exp_hash in expected_hashes.items():
673+
if is_bytes:
674+
digest_object = sslib_hash.digest(algo)
675+
digest_object.update(data)
676+
else:
677+
# if data is not bytes, assume it is a file object
678+
digest_object = sslib_hash.digest_fileobject(data, algo)
679+
680+
observed_hash = digest_object.hexdigest()
681+
if observed_hash != exp_hash:
682+
raise exceptions.LengthOrHashMismatchError(
683+
f"Observed hash {observed_hash} does not match"
684+
f"expected hash {exp_hash}"
685+
)
686+
687+
@staticmethod
688+
def _verify_length(
689+
data: Union[bytes, BinaryIO], expected_length: int
690+
) -> None:
691+
"""Verifies that the length of 'data' matches 'expected_length'"""
692+
if isinstance(data, bytes):
693+
observed_length = len(data)
694+
else:
695+
# if data is not bytes, assume it is a file object
696+
data.seek(0, io.SEEK_END)
697+
observed_length = data.tell()
698+
699+
if observed_length != expected_length:
700+
raise exceptions.LengthOrHashMismatchError(
701+
f"Observed length {observed_length} does not match"
702+
f"expected length {expected_length}"
703+
)
704+
705+
706+
class MetaFile(BaseFile):
648707
"""A container with information about a particular metadata file.
649708
650709
Attributes:
@@ -682,6 +741,13 @@ def from_dict(cls, meta_dict: Dict[str, Any]) -> "MetaFile":
682741
version = meta_dict.pop("version")
683742
length = meta_dict.pop("length", None)
684743
hashes = meta_dict.pop("hashes", None)
744+
745+
# Do some basic input validation
746+
if version <= 0:
747+
raise ValueError(f"Metafile version must be > 0, got {version}")
748+
if length is not None and length <= 0:
749+
raise ValueError(f"Metafile length must be > 0, got {length}")
750+
685751
# All fields left in the meta_dict are unrecognized.
686752
return cls(version, length, hashes, meta_dict)
687753

@@ -700,6 +766,22 @@ def to_dict(self) -> Dict[str, Any]:
700766

701767
return res_dict
702768

769+
def verify_length_and_hashes(self, data: Union[bytes, BinaryIO]):
770+
"""Verifies that the length and hashes of "data" match expected
771+
values.
772+
Args:
773+
data: File object or its content in bytes.
774+
Raises:
775+
LengthOrHashMismatchError: Calculated length or hashes do not
776+
match expected values.
777+
"""
778+
if self.length is not None:
779+
self._verify_length(data, self.length)
780+
781+
# Skip the check in case of an empty dictionary too
782+
if self.hashes:
783+
self._verify_hashes(data, self.hashes)
784+
703785

704786
class Timestamp(Signed):
705787
"""A container for the signed part of timestamp metadata.
@@ -927,7 +1009,7 @@ def to_dict(self) -> Dict[str, Any]:
9271009
}
9281010

9291011

930-
class TargetFile:
1012+
class TargetFile(BaseFile):
9311013
"""A container with information about a particular target file.
9321014
9331015
Attributes:
@@ -945,12 +1027,6 @@ class TargetFile:
9451027
9461028
"""
9471029

948-
@property
949-
def custom(self):
950-
if self.unrecognized_fields is None:
951-
return None
952-
return self.unrecognized_fields.get("custom", None)
953-
9541030
def __init__(
9551031
self,
9561032
length: int,
@@ -961,11 +1037,24 @@ def __init__(
9611037
self.hashes = hashes
9621038
self.unrecognized_fields = unrecognized_fields or {}
9631039

1040+
@property
1041+
def custom(self):
1042+
if self.unrecognized_fields is None:
1043+
return None
1044+
return self.unrecognized_fields.get("custom", None)
1045+
9641046
@classmethod
9651047
def from_dict(cls, target_dict: Dict[str, Any]) -> "TargetFile":
9661048
"""Creates TargetFile object from its dict representation."""
9671049
length = target_dict.pop("length")
9681050
hashes = target_dict.pop("hashes")
1051+
1052+
# Do some basic validation checks
1053+
if length <= 0:
1054+
raise ValueError(f"Targetfile length must be > 0, got {length}")
1055+
if not hashes:
1056+
raise ValueError("Missing targetfile hashes")
1057+
9691058
# All fields left in the target_dict are unrecognized.
9701059
return cls(length, hashes, target_dict)
9711060

@@ -977,6 +1066,18 @@ def to_dict(self) -> Dict[str, Any]:
9771066
**self.unrecognized_fields,
9781067
}
9791068

1069+
def verify_length_and_hashes(self, data: Union[bytes, BinaryIO]):
1070+
"""Verifies that the length and hashes of "data" match expected
1071+
values.
1072+
Args:
1073+
data: File object or its content in bytes.
1074+
Raises:
1075+
LengthOrHashMismatchError: Calculated length or hashes do not
1076+
match expected values.
1077+
"""
1078+
self._verify_length(data, self.length)
1079+
self._verify_hashes(data, self.hashes)
1080+
9801081

9811082
class Targets(Signed):
9821083
"""A container for the signed part of targets metadata.

tuf/exceptions.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ def __repr__(self):
6565
class UnsupportedAlgorithmError(Error):
6666
"""Indicate an error while trying to identify a user-specified algorithm."""
6767

68+
class LengthOrHashMismatchError(Error):
69+
"""Indicate an error while checking the length and hash values of an object"""
6870

6971
class BadHashError(Error):
7072
"""Indicate an error while checking the value of a hash object."""
@@ -88,9 +90,6 @@ def __repr__(self):
8890
# self.__class__.__name__ + '(' + repr(self.expected_hash) + ', ' +
8991
# repr(self.observed_hash) + ')')
9092

91-
92-
93-
9493
class BadVersionNumberError(Error):
9594
"""Indicate an error for metadata that contains an invalid version number."""
9695

0 commit comments

Comments
 (0)