Skip to content

Commit 1404ddd

Browse files
committed
Add in-toto format with hashes of files as subjects
This converts model serialization manifests that record every model file hash into an in-toto payload that can then be passed to Sigstore's `sign_intoto` for signing to generate a Sigstore `Bundle` (if using Sigstore). This time, we record every hash as part of the subject instead of in the payload. We require verifiers to be aware of this and acknowledge that verifiers that only check subject by subject (that is, they check if the hash of a passed in argument is in the list of subjects and don't check if all the hashes are present), can fail to fully detect if the model integrity is compromised by renaming one file in the model, interchanging two file names, or deleting a file. The signing library will have additional checks for this, but verifying the signature with other tools might result in invalid results. Signed-off-by: Mihai Maruseac <[email protected]>
1 parent 2cc7279 commit 1404ddd

9 files changed

+408
-0
lines changed

model_signing/signing/in_toto.py

+129
Original file line numberDiff line numberDiff line change
@@ -352,3 +352,132 @@ def from_manifest(cls, manifest: manifest_module.Manifest) -> Self:
352352
predicate_top_level_name="shards",
353353
)
354354
return cls(statement)
355+
356+
357+
def _convert_descriptors_to_direct_statement(
358+
manifest: manifest_module.Manifest, predicate_type: str
359+
):
360+
"""Converts manifest descriptors to an in-toto statement, as subjects.
361+
362+
Args:
363+
manifest: The manifest to extract the descriptors from. Assumed valid.
364+
predicate_type: The predicate_type to use in the in-toto statement.
365+
"""
366+
subjects = []
367+
for descriptor in manifest.resource_descriptors():
368+
subject = statement.ResourceDescriptor(
369+
name=descriptor.identifier,
370+
digest={"sha256": descriptor.digest.digest_hex},
371+
annotations={"actual_hash_algorithm": descriptor.digest.algorithm},
372+
)
373+
subjects.append(subject.pb)
374+
375+
return statement.Statement(
376+
subjects=subjects,
377+
predicate_type=predicate_type,
378+
# https://github.com/in-toto/attestation/issues/374
379+
predicate={"unused":"Unused, just passed due to API requirements"},
380+
)
381+
382+
383+
class DigestsIntotoPayload(IntotoPayload):
384+
"""In-toto payload where the subjects are the model files themselves.
385+
386+
This payload is supposed to be used for manifests where every file in the
387+
model is matched with a digest. Because existing tooling only supports
388+
established hashing algorithms, we annotate every subject with the actual
389+
hash algorithm used to compute the file digest, and use "sha256" as the
390+
algorithm name in the digest itself.
391+
392+
Example:
393+
```json
394+
{
395+
"_type": "https://in-toto.io/Statement/v1",
396+
"subject": [
397+
{
398+
"name": "d0/d1/d2/d3/d4/f0",
399+
"digest": {
400+
"sha256": "6efa14..."
401+
},
402+
"annotations": {
403+
"actual_hash_algorithm": "file-sha256"
404+
}
405+
},
406+
{
407+
"name": "d0/d1/d2/d3/d4/f1",
408+
"digest": {
409+
"sha256": "a9bc14..."
410+
},
411+
"annotations": {
412+
"actual_hash_algorithm": "file-sha256"
413+
}
414+
},
415+
{
416+
"name": "d0/d1/d2/d3/d4/f2",
417+
"digest": {
418+
"sha256": "5f597e..."
419+
},
420+
"annotations": {
421+
"actual_hash_algorithm": "file-sha256"
422+
}
423+
},
424+
{
425+
"name": "d0/d1/d2/d3/d4/f3",
426+
"digest": {
427+
"sha256": "eaf677..."
428+
},
429+
"annotations": {
430+
"actual_hash_algorithm": "file-sha256"
431+
}
432+
}
433+
],
434+
"predicateType": "https://model_signing/Digests/v0.1",
435+
"predicate": {
436+
"unused": "Unused, just passed due to API requirements"
437+
}
438+
}
439+
```
440+
441+
If the annotation for a subject is missing, or it does not contain
442+
actual_hash_algorithm, it should be assumed that the digest is computed via
443+
the algorithm listed in the digest dictionary (i.e., sha256).
444+
445+
See also https://github.com/sigstore/sigstore-python/issues/1018.
446+
"""
447+
448+
predicate_type: Final[str] = "https://model_signing/Digests/v0.1"
449+
450+
def __init__(self, statement: statement.Statement):
451+
"""Builds an instance of this in-toto payload.
452+
453+
Don't call this directly in production. Use `from_manifest()` instead.
454+
455+
Args:
456+
statement: The DSSE statement representing this in-toto payload.
457+
"""
458+
self.statement = statement
459+
460+
@classmethod
461+
@override
462+
def from_manifest(cls, manifest: manifest_module.Manifest) -> Self:
463+
"""Converts a manifest to the signing payload used for signing.
464+
465+
The manifest must be one where every model file is paired with its own
466+
digest. Currently, this is only `FileLevelManifest`.
467+
468+
Args:
469+
manifest: the manifest to convert to signing payload.
470+
471+
Returns:
472+
An instance of `DigestOfDigestsIntotoPayload`.
473+
474+
Raises:
475+
TypeError: If the manifest is not `FileLevelManifest`.
476+
"""
477+
if not isinstance(manifest, manifest_module.FileLevelManifest):
478+
raise TypeError("Only FileLevelManifest is supported")
479+
480+
statement = _convert_descriptors_to_direct_statement(
481+
manifest, predicate_type=cls.predicate_type
482+
)
483+
return cls(statement)

model_signing/signing/in_toto_test.py

+56
Original file line numberDiff line numberDiff line change
@@ -208,3 +208,59 @@ def test_only_runs_on_expected_manifest_types(self):
208208
match="Only ShardLevelManifest is supported",
209209
):
210210
in_toto.DigestOfShardDigestsIntotoPayload.from_manifest(manifest)
211+
212+
213+
class TestDigestsIntotoPayload:
214+
215+
def _hasher_factory(self, path: pathlib.Path) -> file.FileHasher:
216+
return file.SimpleFileHasher(path, memory.SHA256())
217+
218+
@pytest.mark.parametrize("model_fixture_name", test_support.all_test_models)
219+
def test_known_models(self, request, model_fixture_name):
220+
# Set up variables (arrange)
221+
testdata_path = request.path.parent / "testdata"
222+
test_path = testdata_path / "in_toto"
223+
test_class_path = test_path / "TestDigestsIntotoPayload"
224+
golden_path = test_class_path / model_fixture_name
225+
should_update = request.config.getoption("update_goldens")
226+
model = request.getfixturevalue(model_fixture_name)
227+
228+
# Compute payload (act)
229+
serializer = serialize_by_file.ManifestSerializer(
230+
self._hasher_factory, allow_symlinks=True
231+
)
232+
manifest = serializer.serialize(model)
233+
payload = in_toto.DigestsIntotoPayload.from_manifest(manifest)
234+
235+
# Compare with golden, or write to golden (approximately "assert")
236+
if should_update:
237+
with open(golden_path, "w", encoding="utf-8") as f:
238+
f.write(f"{json_format.MessageToJson(payload.statement.pb)}\n")
239+
else:
240+
with open(golden_path, "r", encoding="utf-8") as f:
241+
json_contents = f.read()
242+
expected_proto = json_format.Parse(
243+
json_contents, statement_pb2.Statement()
244+
)
245+
246+
assert payload.statement.pb == expected_proto
247+
248+
def test_produces_valid_statements(self, sample_model_folder):
249+
serializer = serialize_by_file.ManifestSerializer(
250+
self._hasher_factory, allow_symlinks=True
251+
)
252+
manifest = serializer.serialize(sample_model_folder)
253+
254+
payload = in_toto.DigestsIntotoPayload.from_manifest(manifest)
255+
256+
payload.statement.validate()
257+
258+
def test_only_runs_on_expected_manifest_types(self):
259+
digest = hashing.Digest("test", b"test_digest")
260+
manifest = manifest_module.DigestManifest(digest)
261+
262+
with pytest.raises(
263+
TypeError,
264+
match="Only FileLevelManifest is supported",
265+
):
266+
in_toto.DigestsIntotoPayload.from_manifest(manifest)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
{
2+
"_type": "https://in-toto.io/Statement/v1",
3+
"subject": [
4+
{
5+
"name": "d0/d1/d2/d3/d4/f0",
6+
"digest": {
7+
"sha256": "6efa14bb03544fcb76045c55f25b9315b6eb5be2d8a85f703193a76b7874c6ff"
8+
},
9+
"annotations": {
10+
"actual_hash_algorithm": "file-sha256"
11+
}
12+
},
13+
{
14+
"name": "d0/d1/d2/d3/d4/f1",
15+
"digest": {
16+
"sha256": "a9bc149b70b9d325cd68d275d582cfdb98c0347d3ce54590aa6533368daed3d2"
17+
},
18+
"annotations": {
19+
"actual_hash_algorithm": "file-sha256"
20+
}
21+
},
22+
{
23+
"name": "d0/d1/d2/d3/d4/f2",
24+
"digest": {
25+
"sha256": "5f597e6a92d1324d9adbed43d527926d11d0131487baf315e65ae1ef3b1ca3c0"
26+
},
27+
"annotations": {
28+
"actual_hash_algorithm": "file-sha256"
29+
}
30+
},
31+
{
32+
"name": "d0/d1/d2/d3/d4/f3",
33+
"digest": {
34+
"sha256": "eaf677c35fec6b87889d9e4563d8bb65dcb9869ca0225697c9cc44cf49dca008"
35+
},
36+
"annotations": {
37+
"actual_hash_algorithm": "file-sha256"
38+
}
39+
}
40+
],
41+
"predicateType": "https://model_signing/Digests/v0.1",
42+
"predicate": {
43+
"unused": "Unused, just passed due to API requirements"
44+
}
45+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"_type": "https://in-toto.io/Statement/v1",
3+
"subject": [
4+
{
5+
"name": ".",
6+
"digest": {
7+
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
8+
},
9+
"annotations": {
10+
"actual_hash_algorithm": "file-sha256"
11+
}
12+
}
13+
],
14+
"predicateType": "https://model_signing/Digests/v0.1",
15+
"predicate": {
16+
"unused": "Unused, just passed due to API requirements"
17+
}
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"_type": "https://in-toto.io/Statement/v1",
3+
"predicateType": "https://model_signing/Digests/v0.1",
4+
"predicate": {
5+
"unused": "Unused, just passed due to API requirements"
6+
}
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"_type": "https://in-toto.io/Statement/v1",
3+
"subject": [
4+
{
5+
"name": "empty_file",
6+
"digest": {
7+
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
8+
},
9+
"annotations": {
10+
"actual_hash_algorithm": "file-sha256"
11+
}
12+
}
13+
],
14+
"predicateType": "https://model_signing/Digests/v0.1",
15+
"predicate": {
16+
"unused": "Unused, just passed due to API requirements"
17+
}
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"_type": "https://in-toto.io/Statement/v1",
3+
"subject": [
4+
{
5+
"name": ".",
6+
"digest": {
7+
"sha256": "3aab065c7181a173b5dd9e9d32a9f79923440b413be1e1ffcdba26a7365f719b"
8+
},
9+
"annotations": {
10+
"actual_hash_algorithm": "file-sha256"
11+
}
12+
}
13+
],
14+
"predicateType": "https://model_signing/Digests/v0.1",
15+
"predicate": {
16+
"unused": "Unused, just passed due to API requirements"
17+
}
18+
}

0 commit comments

Comments
 (0)