Skip to content

Commit 997395a

Browse files
authored
Ignore unsigned files (only read and hash files contained in signature manifest) (#501)
* Add support for ignoring files that are not part of signature/manifest Add support for command line option --ignore-unsigned-files that allows to ignore files that are not part of the signature (= not listed in the manifest). This allows to ignore files that for example were added after a signature was created and those files' presence would make the signature verification fail. Another use case for this is the presence of multiple signatures in the same directory where it is necessary to ignore the files that are not covered by each signature. Add support for this option for all verification methods. If this option is set, then create a list of files_to_hash that is derived from the list of files in the signature manifest. Signed-off-by: Stefan Berger <[email protected]> * tests: Add test cases for ignoring unsigned files Add test cases covering the cases of a symlink and an additional file created after a signature was created. Test expected failures and passes with and without the new option --ignore_unsigned_files. Signed-off-by: Stefan Berger <[email protected]> --------- Signed-off-by: Stefan Berger <[email protected]>
1 parent 8cb6f18 commit 997395a

File tree

7 files changed

+175
-8
lines changed

7 files changed

+175
-8
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ All versions prior to 1.0.0 are untracked.
2525
- Add warning for older verification material formats (e.g., raw public key bytes) during verification, recommending re-signing
2626
- Added guidance to `README.md` on how to install `model-signing` with PKCS#11 support.
2727
- Added support trace sigstore sign and verify operations using OpenTelemetry.
28+
- cli: Added support for `--ignore_unsigned_files` option
2829

2930
## [1.0.1] - 2024-04-18
3031

scripts/tests/test-sign-verify

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,40 @@ if [ "${res}" != "${exp}" ]; then
159159
fi
160160
check_model_name "${sigfile}" "$(basename "${TMPDIR}")"
161161

162+
echo
163+
echo "Creating a symlink, that is not part of the signature, to make signature verification fail (1)"
164+
165+
echo "foo" > symlinked
166+
ln -s symlinked symlink
167+
168+
if python -m model_signing \
169+
verify certificate \
170+
--signature "$(basename "${sigfile}")" \
171+
--certificate_chain "${DIR}/keys/certificate/ca-cert.pem" \
172+
--ignore-paths "$(basename "${ignorefile}")" \
173+
. ; then
174+
echo "Error: 'verify certificate' succeeded after new file (symlink) was created"
175+
exit 1
176+
fi
177+
178+
# This should pass without having to pass --allow_symlinks since the symlink
179+
# will be ignored
180+
echo
181+
echo "Pass signature verification by ignoring any unsigned files or symlinks"
182+
if ! python -m model_signing \
183+
verify certificate \
184+
--signature "$(basename "${sigfile}")" \
185+
--certificate_chain "${DIR}/keys/certificate/ca-cert.pem" \
186+
--ignore-paths "$(basename "${ignorefile}")" \
187+
--ignore_unsigned_files \
188+
. ; then
189+
echo "Error: 'verify certificate' failed with --ignore_unsigned_files while passing --allow_symlinks"
190+
exit 1
191+
fi
192+
193+
194+
rm -f symlinked symlink
195+
162196
echo
163197
echo "Testing 'sign/verify' certificate when in subdir of model directory and using '..'"
164198

@@ -208,4 +242,71 @@ if [ "${res}" != "${exp}" ]; then
208242
fi
209243
check_model_name "${sigfile}" "$(basename "${TMPDIR}")"
210244

245+
echo
246+
echo "Creating a symlink, that is not part of the signature, to make signature verification fail (2)"
247+
248+
# Create another symlinked file
249+
ln -s subdir/symlinked symlink2
250+
251+
echo
252+
echo "Fail signature verification by NOT ignoring any unsigned (symlinks)"
253+
if python -m model_signing \
254+
verify certificate \
255+
--signature "$(basename "${sigfile}")" \
256+
--certificate_chain "${DIR}/keys/certificate/ca-cert.pem" \
257+
--ignore-paths "$(basename "${ignorefile}")" \
258+
--allow_symlinks \
259+
. ; then
260+
echo "Error: 'verify certificate' succeeded after new file (symlink) was created"
261+
exit 1
262+
fi
263+
264+
echo
265+
echo "Pass signature verification by ignoring any unsigned files"
266+
if ! python -m model_signing \
267+
verify certificate \
268+
--signature "$(basename "${sigfile}")" \
269+
--certificate_chain "${DIR}/keys/certificate/ca-cert.pem" \
270+
--ignore-paths "$(basename "${ignorefile}")" \
271+
--allow_symlinks \
272+
--ignore_unsigned_files \
273+
. ; then
274+
echo "Error: 'verify certificate' failed with --ignore_unsigned_files to ignore symlink"
275+
exit 1
276+
fi
277+
278+
rm symlink2
279+
280+
# Create a simple new file
281+
echo
282+
echo "Creating a regular file that is not part of the signature"
283+
touch newfile
284+
285+
echo
286+
echo "Fail signature verification by NOT ignoring any unsigned files (symlinks)"
287+
if python -m model_signing \
288+
verify certificate \
289+
--signature "$(basename "${sigfile}")" \
290+
--certificate_chain "${DIR}/keys/certificate/ca-cert.pem" \
291+
--ignore-paths "$(basename "${ignorefile}")" \
292+
--allow_symlinks \
293+
. ; then
294+
echo "Error: 'verify certificate' succeeded after new file was created"
295+
exit 1
296+
fi
297+
298+
echo
299+
echo "Pass signature verification by ignoring any unsigned files"
300+
if ! python -m model_signing \
301+
verify certificate \
302+
--signature "$(basename "${sigfile}")" \
303+
--certificate_chain "${DIR}/keys/certificate/ca-cert.pem" \
304+
--ignore-paths "$(basename "${ignorefile}")" \
305+
--allow_symlinks \
306+
--ignore_unsigned_files \
307+
. ; then
308+
echo "Error: 'verify certificate' failed with --ignore_unsigned_files to ignore regular file"
309+
exit 1
310+
fi
311+
211312
popd 1>/dev/null || exit 1

src/model_signing/_cli.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ def set_attribute(self, key, value):
8686
help="Ignore git-related files when signing or verifying.",
8787
)
8888

89+
# Decorator for the commonly used option to ignore all unsigned files
90+
_ignore_unsigned_files_option = click.option(
91+
"--ignore_unsigned_files/--no-ignore_unsigned_files",
92+
type=bool,
93+
show_default=True,
94+
help="Ignore all files that were not originally signed.",
95+
)
96+
8997
# Decorator for the commonly used option to set the path to the private key
9098
# (when using non-Sigstore PKI).
9199
_private_key_option = click.option(
@@ -598,6 +606,7 @@ def _verify() -> None:
598606
required=True,
599607
help="The expected identity provider (e.g., https://accounts.example.com).",
600608
)
609+
@_ignore_unsigned_files_option
601610
def _verify_sigstore(
602611
model_path: pathlib.Path,
603612
signature: pathlib.Path,
@@ -607,6 +616,7 @@ def _verify_sigstore(
607616
identity: str,
608617
identity_provider: str,
609618
use_staging: bool,
619+
ignore_unsigned_files: bool,
610620
) -> None:
611621
"""Verify using Sigstore (DEFAULT verification method).
612622
@@ -637,7 +647,9 @@ def _verify_sigstore(
637647
ignore_git_paths=ignore_git_paths,
638648
)
639649
.set_allow_symlinks(allow_symlinks)
640-
).verify(model_path, signature)
650+
).set_ignore_unsigned_files(ignore_unsigned_files).verify(
651+
model_path, signature
652+
)
641653
except Exception as err:
642654
click.echo(f"Verification failed with error: {err}", err=True)
643655
sys.exit(1)
@@ -658,13 +670,15 @@ def _verify_sigstore(
658670
required=True,
659671
help="Path to the public key used for verification.",
660672
)
673+
@_ignore_unsigned_files_option
661674
def _verify_private_key(
662675
model_path: pathlib.Path,
663676
signature: pathlib.Path,
664677
ignore_paths: Iterable[pathlib.Path],
665678
ignore_git_paths: bool,
666679
allow_symlinks: bool,
667680
public_key: pathlib.Path,
681+
ignore_unsigned_files: bool,
668682
) -> None:
669683
"""Verity using a public key (paired with a private one).
670684
@@ -689,7 +703,9 @@ def _verify_private_key(
689703
ignore_git_paths=ignore_git_paths,
690704
)
691705
.set_allow_symlinks(allow_symlinks)
692-
).verify(model_path, signature)
706+
).set_ignore_unsigned_files(ignore_unsigned_files).verify(
707+
model_path, signature
708+
)
693709
except Exception as err:
694710
click.echo(f"Verification failed with error: {err}", err=True)
695711
sys.exit(1)
@@ -712,6 +728,7 @@ def _verify_private_key(
712728
show_default=True,
713729
help="Log SHA256 fingerprints of all certificates.",
714730
)
731+
@_ignore_unsigned_files_option
715732
def _verify_certificate(
716733
model_path: pathlib.Path,
717734
signature: pathlib.Path,
@@ -720,6 +737,7 @@ def _verify_certificate(
720737
allow_symlinks: bool,
721738
certificate_chain: Iterable[pathlib.Path],
722739
log_fingerprints: bool,
740+
ignore_unsigned_files: bool,
723741
) -> None:
724742
"""Verify using a certificate.
725743
@@ -748,7 +766,9 @@ def _verify_certificate(
748766
ignore_git_paths=ignore_git_paths,
749767
)
750768
.set_allow_symlinks(allow_symlinks)
751-
).verify(model_path, signature)
769+
).set_ignore_unsigned_files(ignore_unsigned_files).verify(
770+
model_path, signature
771+
)
752772
except Exception as err:
753773
click.echo(f"Verification failed with error: {err}", err=True)
754774
sys.exit(1)

src/model_signing/_serialization/file.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ def serialize(
7676
model_path: pathlib.Path,
7777
*,
7878
ignore_paths: Iterable[pathlib.Path] = frozenset(),
79+
files_to_hash: Optional[Iterable[pathlib.Path]] = None,
7980
) -> manifest.Manifest:
8081
"""Serializes the model given by the `model_path` argument.
8182
@@ -84,6 +85,8 @@ def serialize(
8485
ignore_paths: The paths to ignore during serialization. If a
8586
provided path is a directory, all children of the directory are
8687
ignored.
88+
files_to_hash: Optional list of files that are to be hashed;
89+
ignore all others
8790
8891
Returns:
8992
The model's serialized manifest.
@@ -97,7 +100,11 @@ def serialize(
97100
# Python3.12 is the minimum supported version, the glob can be replaced
98101
# with `pathlib.Path.walk` for a clearer interface, and some speed
99102
# improvement.
100-
for path in itertools.chain((model_path,), model_path.glob("**/*")):
103+
if files_to_hash is None:
104+
files_to_hash = itertools.chain(
105+
(model_path,), model_path.glob("**/*")
106+
)
107+
for path in files_to_hash:
101108
serialization.check_file_or_directory(
102109
path, allow_symlinks=self._allow_symlinks
103110
)

src/model_signing/_serialization/file_shard.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ def serialize(
110110
model_path: pathlib.Path,
111111
*,
112112
ignore_paths: Iterable[pathlib.Path] = frozenset(),
113+
files_to_hash: Optional[Iterable[pathlib.Path]] = None,
113114
) -> manifest.Manifest:
114115
"""Serializes the model given by the `model_path` argument.
115116
@@ -118,6 +119,8 @@ def serialize(
118119
ignore_paths: The paths to ignore during serialization. If a
119120
provided path is a directory, all children of the directory are
120121
ignored.
122+
files_to_hash: Opitonal list of files that are to be hashed;
123+
ignore all others
121124
122125
Returns:
123126
The model's serialized manifest.
@@ -131,7 +134,11 @@ def serialize(
131134
# Python3.12 is the minimum supported version, the glob can be replaced
132135
# with `pathlib.Path.walk` for a clearer interface, and some speed
133136
# improvement.
134-
for path in itertools.chain((model_path,), model_path.glob("**/*")):
137+
if files_to_hash is None:
138+
files_to_hash = itertools.chain(
139+
(model_path,), model_path.glob("**/*")
140+
)
141+
for path in files_to_hash:
135142
serialization.check_file_or_directory(
136143
path, allow_symlinks=self._allow_symlinks
137144
)

src/model_signing/hashing.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,12 @@ def __init__(self):
141141
self.use_file_serialization()
142142
self._allow_symlinks = False
143143

144-
def hash(self, model_path: PathLike) -> manifest.Manifest:
144+
def hash(
145+
self,
146+
model_path: PathLike,
147+
*,
148+
files_to_hash: Optional[Iterable[PathLike]] = None,
149+
) -> manifest.Manifest:
145150
"""Hashes a model using the current configuration."""
146151
# All paths in ignored_paths must have model_path as prefix
147152
ignored_paths = []
@@ -167,7 +172,9 @@ def hash(self, model_path: PathLike) -> manifest.Manifest:
167172
self._serializer.set_allow_symlinks(self._allow_symlinks)
168173

169174
return self._serializer.serialize(
170-
pathlib.Path(model_path), ignore_paths=ignored_paths
175+
pathlib.Path(model_path),
176+
ignore_paths=ignored_paths,
177+
files_to_hash=files_to_hash,
171178
)
172179

173180
def _build_stream_hasher(

src/model_signing/verifying.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ def __init__(self):
7373
self._hashing_config = None
7474
self._verifier = None
7575
self._uses_sigstore = False
76+
self._ignore_unsigned_files = False
7677

7778
def verify(
7879
self, model_path: hashing.PathLike, signature_path: hashing.PathLike
@@ -102,7 +103,18 @@ def verify(
102103
model_path=model_path,
103104
paths=expected_manifest.serialization_type["ignore_paths"],
104105
)
105-
actual_manifest = self._hashing_config.hash(model_path)
106+
107+
if self._ignore_unsigned_files:
108+
files_to_hash = [
109+
model_path / rd.identifier
110+
for rd in expected_manifest.resource_descriptors()
111+
]
112+
else:
113+
files_to_hash = None
114+
115+
actual_manifest = self._hashing_config.hash(
116+
model_path, files_to_hash=files_to_hash
117+
)
106118

107119
if actual_manifest != expected_manifest:
108120
diff_message = self._get_manifest_diff(
@@ -164,6 +176,18 @@ def set_hashing_config(self, hashing_config: hashing.Config) -> Self:
164176
self._hashing_config = hashing_config
165177
return self
166178

179+
def set_ignore_unsigned_files(self, ignore_unsigned_files: bool) -> Self:
180+
"""Sets whether files that were not signed are to be ignored.
181+
182+
This method allows to ignore those files that are not part of the
183+
manifest and therefor were not originally signed.
184+
185+
Args:
186+
ignore_unsigned_files: whether to ignore unsigned files
187+
"""
188+
self._ignore_unsigned_files = ignore_unsigned_files
189+
return self
190+
167191
def _guess_hashing_config(self, source_manifest: manifest.Manifest) -> None:
168192
"""Attempts to guess the hashing config from a manifest."""
169193
args = source_manifest.serialization_type

0 commit comments

Comments
 (0)