Skip to content

Commit eea7315

Browse files
Add support for verifying digests to CLI verify commands (#1125)
Co-authored-by: William Woodruff <william@trailofbits.com>
1 parent 55e8d15 commit eea7315

File tree

5 files changed

+179
-36
lines changed

5 files changed

+179
-36
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ All versions prior to 0.9.0 are untracked.
2222
a path to the file containing the predicate, and the predicate type.
2323
Currently only the SLSA Provenance v0.2 and v1.0 types are supported.
2424

25+
* CLI: The `sigstore verify` command now supports verifying digests. This means
26+
that the user can now pass a digest like `sha256:aaaa....` instead of the
27+
path to an artifact, and `sigstore-python` will verify it as if it was the
28+
artifact with that digest.
29+
2530
## [3.2.0]
2631

2732
### Added

README.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ usage: sigstore verify identity [-h] [-v] [--certificate FILE]
247247
[--signature FILE] [--bundle FILE] [--offline]
248248
--cert-identity IDENTITY --cert-oidc-issuer
249249
URL
250-
FILE [FILE ...]
250+
FILE_OR_DIGEST [FILE_OR_DIGEST ...]
251251

252252
optional arguments:
253253
-h, --help show this help message and exit
@@ -262,7 +262,8 @@ Verification inputs:
262262
multiple inputs (default: None)
263263
--bundle FILE The Sigstore bundle to verify with; not used with
264264
multiple inputs (default: None)
265-
FILE The file to verify
265+
FILE_OR_DIGEST The file path or the digest to verify. The digest
266+
should start with the 'sha256:' prefix.
266267

267268
Verification options:
268269
--offline Perform offline verification; requires a Sigstore
@@ -290,7 +291,7 @@ usage: sigstore verify github [-h] [-v] [--certificate FILE]
290291
[--cert-identity IDENTITY] [--trigger EVENT]
291292
[--sha SHA] [--name NAME] [--repository REPO]
292293
[--ref REF]
293-
FILE [FILE ...]
294+
FILE_OR_DIGEST [FILE_OR_DIGEST ...]
294295

295296
optional arguments:
296297
-h, --help show this help message and exit
@@ -305,7 +306,8 @@ Verification inputs:
305306
multiple inputs (default: None)
306307
--bundle FILE The Sigstore bundle to verify with; not used with
307308
multiple inputs (default: None)
308-
FILE The file to verify
309+
FILE_OR_DIGEST The file path or the digest to verify. The digest
310+
should start with the 'sha256:' prefix.
309311

310312
Verification options:
311313
--offline Perform offline verification; requires a Sigstore
@@ -421,6 +423,18 @@ $ python -m sigstore verify identity foo.txt bar.txt \
421423
--cert-oidc-issuer 'https://github.com/login/oauth'
422424
```
423425

426+
### Verifying a digest instead of a file
427+
428+
`sigstore-python` supports verifying digests directly, without requiring the artifact to be
429+
present. The digest should be prefixed with the `sha256:` string:
430+
431+
```console
432+
$ python -m sigstore verify identity sha256:ce8ab2822671752e201ea1e19e8c85e73d497e1c315bfd9c25f380b7625d1691 \
433+
--cert-identity 'hamilcar@example.com' \
434+
--cert-oidc-issuer 'https://github.com/login/oauth'
435+
--bundle 'foo.txt.sigstore.json'
436+
```
437+
424438
### Verifying signatures from GitHub Actions
425439

426440
`sigstore verify github` can be used to verify claims specific to signatures coming from GitHub

sigstore/_cli.py

Lines changed: 114 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from sigstore_protobuf_specs.dev.sigstore.bundle.v1 import (
3333
Bundle as RawBundle,
3434
)
35+
from sigstore_protobuf_specs.dev.sigstore.common.v1 import HashAlgorithm
3536
from typing_extensions import TypeAlias
3637

3738
from sigstore import __version__, dsse
@@ -81,6 +82,21 @@ class SigningOutputs:
8182
bundle: Optional[Path] = None
8283

8384

85+
@dataclass(frozen=True)
86+
class VerificationUnbundledMaterials:
87+
certificate: Path
88+
signature: Path
89+
90+
91+
@dataclass(frozen=True)
92+
class VerificationBundledMaterials:
93+
bundle: Path
94+
95+
96+
VerificationMaterials: TypeAlias = Union[
97+
VerificationUnbundledMaterials, VerificationBundledMaterials
98+
]
99+
84100
# Map of inputs -> outputs for signing operations
85101
OutputMap: TypeAlias = Dict[Path, SigningOutputs]
86102

@@ -149,12 +165,25 @@ def _add_shared_verify_input_options(group: argparse._ArgumentGroup) -> None:
149165
default=os.getenv("SIGSTORE_BUNDLE"),
150166
help=("The Sigstore bundle to verify with; not used with multiple inputs"),
151167
)
168+
169+
def file_or_digest(arg: str) -> Hashed | Path:
170+
if arg.startswith("sha256:"):
171+
digest = bytes.fromhex(arg[len("sha256:") :])
172+
if len(digest) != 32:
173+
raise ValueError()
174+
return Hashed(
175+
digest=digest,
176+
algorithm=HashAlgorithm.SHA2_256,
177+
)
178+
else:
179+
return Path(arg)
180+
152181
group.add_argument(
153-
"files",
154-
metavar="FILE",
155-
type=Path,
182+
"files_or_digest",
183+
metavar="FILE_OR_DIGEST",
184+
type=file_or_digest,
156185
nargs="+",
157-
help="The file to verify",
186+
help="The file path or the digest to verify. The digest should start with the 'sha256:' prefix.",
158187
)
159188

160189

@@ -826,7 +855,7 @@ def _sign(args: argparse.Namespace) -> None:
826855

827856
def _collect_verification_state(
828857
args: argparse.Namespace,
829-
) -> tuple[Verifier, list[tuple[Path, Hashed, Bundle]]]:
858+
) -> tuple[Verifier, list[tuple[Path | Hashed, Hashed, Bundle]]]:
830859
"""
831860
Performs CLI functionality common across all `sigstore verify` subcommands.
832861
@@ -835,13 +864,15 @@ def _collect_verification_state(
835864
pre-hashed input to the file being verified and `bundle` is the `Bundle` to verify with.
836865
"""
837866

838-
# Fail if --certificate, --signature, or --bundle is specified and we
867+
# Fail if --certificate, --signature, or --bundle is specified, and we
839868
# have more than one input.
840-
if (args.certificate or args.signature or args.bundle) and len(args.files) > 1:
869+
if (args.certificate or args.signature or args.bundle) and len(
870+
args.files_or_digest
871+
) > 1:
841872
_invalid_arguments(
842873
args,
843874
"--certificate, --signature, or --bundle can only be used "
844-
"with a single input file",
875+
"with a single input file or digest",
845876
)
846877

847878
# Fail if `--certificate` or `--signature` is used with `--bundle`.
@@ -850,6 +881,14 @@ def _collect_verification_state(
850881
args, "--bundle cannot be used with --certificate or --signature"
851882
)
852883

884+
# Fail if digest input is not used with `--bundle` or both `--certificate` and `--signature`.
885+
if any((isinstance(x, Hashed) for x in args.files_or_digest)):
886+
if not args.bundle and not (args.certificate and args.signature):
887+
_invalid_arguments(
888+
args,
889+
"verifying a digest input (sha256:*) needs either --bundle or both --certificate and --signature",
890+
)
891+
853892
# Fail if `--certificate` or `--signature` is used with `--offline`.
854893
if args.offline and (args.certificate or args.signature):
855894
_invalid_arguments(
@@ -858,8 +897,8 @@ def _collect_verification_state(
858897

859898
# The converse of `sign`: we build up an expected input map and check
860899
# that we have everything so that we can fail early.
861-
input_map = {}
862-
for file in args.files:
900+
input_map: dict[Path | Hashed, VerificationMaterials] = {}
901+
for file in (f for f in args.files_or_digest if isinstance(f, Path)):
863902
if not file.is_file():
864903
_invalid_arguments(args, f"Input must be a file: {file}")
865904

@@ -900,21 +939,61 @@ def _collect_verification_state(
900939
missing.append(str(sig))
901940
if not cert.is_file():
902941
missing.append(str(cert))
903-
input_map[file] = {"cert": cert, "sig": sig}
942+
input_map[file] = VerificationUnbundledMaterials(
943+
certificate=cert, signature=sig
944+
)
904945
else:
905946
# If a user hasn't explicitly supplied `--signature` or `--certificate`,
906947
# we expect a bundle either supplied via `--bundle` or with the
907948
# default `{input}.sigstore(.json)?` name.
908949
if not bundle.is_file():
909950
missing.append(str(bundle))
910951

911-
input_map[file] = {"bundle": bundle}
952+
input_map[file] = VerificationBundledMaterials(bundle=bundle)
912953

913954
if missing:
914955
_invalid_arguments(
915956
args,
916957
f"Missing verification materials for {(file)}: {', '.join(missing)}",
917958
)
959+
960+
if not input_map:
961+
if len(args.files_or_digest) != 1:
962+
# This should never happen, since if `input_map` is empty that means there
963+
# were no file inputs, and therefore exactly one digest input should be
964+
# present.
965+
_invalid_arguments(
966+
args, "Internal error: Found multiple digests in CLI arguments"
967+
)
968+
hashed = args.files_or_digest[0]
969+
sig, cert, bundle = (
970+
args.signature,
971+
args.certificate,
972+
args.bundle,
973+
)
974+
missing = []
975+
if args.signature or args.certificate:
976+
if not sig.is_file():
977+
missing.append(str(sig))
978+
if not cert.is_file():
979+
missing.append(str(cert))
980+
input_map[hashed] = VerificationUnbundledMaterials(
981+
certificate=cert, signature=sig
982+
)
983+
else:
984+
# If a user hasn't explicitly supplied `--signature` or `--certificate`,
985+
# we expect a bundle supplied via `--bundle`
986+
if not bundle.is_file():
987+
missing.append(str(bundle))
988+
989+
input_map[hashed] = VerificationBundledMaterials(bundle=bundle)
990+
991+
if missing:
992+
_invalid_arguments(
993+
args,
994+
f"Missing verification materials for {(hashed)}: {', '.join(missing)}",
995+
)
996+
918997
if args.staging:
919998
_logger.debug("verify: staging instances requested")
920999
verifier = Verifier.staging()
@@ -925,24 +1004,27 @@ def _collect_verification_state(
9251004
verifier = Verifier.production()
9261005

9271006
all_materials = []
928-
for file, inputs in input_map.items():
929-
with file.open(mode="rb") as io:
930-
hashed = sha256_digest(io)
1007+
for file_or_hashed, materials in input_map.items():
1008+
if isinstance(file_or_hashed, Path):
1009+
with file_or_hashed.open(mode="rb") as io:
1010+
hashed = sha256_digest(io)
1011+
else:
1012+
hashed = file_or_hashed
9311013

932-
if "bundle" in inputs:
1014+
if isinstance(materials, VerificationBundledMaterials):
9331015
# Load the bundle
934-
_logger.debug(f"Using bundle from: {inputs['bundle']}")
1016+
_logger.debug(f"Using bundle from: {materials.bundle}")
9351017

936-
bundle_bytes = inputs["bundle"].read_bytes()
1018+
bundle_bytes = materials.bundle.read_bytes()
9371019
bundle = Bundle.from_json(bundle_bytes)
9381020
else:
9391021
# Load the signing certificate
940-
_logger.debug(f"Using certificate from: {inputs['cert']}")
941-
cert = load_pem_x509_certificate(inputs["cert"].read_bytes())
1022+
_logger.debug(f"Using certificate from: {materials.certificate}")
1023+
cert = load_pem_x509_certificate(materials.certificate.read_bytes())
9421024

9431025
# Load the signature
944-
_logger.debug(f"Using signature from: {inputs['sig']}")
945-
b64_signature = inputs["sig"].read_text()
1026+
_logger.debug(f"Using signature from: {materials.signature}")
1027+
b64_signature = materials.signature.read_text()
9461028
signature = base64.b64decode(b64_signature)
9471029

9481030
# When using "detached" materials, we *must* retrieve the log
@@ -953,33 +1035,34 @@ def _collect_verification_state(
9531035
)
9541036
if log_entry is None:
9551037
_invalid_arguments(
956-
args, f"No matching log entry for {file}'s verification materials"
1038+
args,
1039+
f"No matching log entry for {file_or_hashed}'s verification materials",
9571040
)
9581041
bundle = Bundle.from_parts(cert, signature, log_entry)
9591042

960-
_logger.debug(f"Verifying contents from: {file}")
1043+
_logger.debug(f"Verifying contents from: {file_or_hashed}")
9611044

962-
all_materials.append((file, hashed, bundle))
1045+
all_materials.append((file_or_hashed, hashed, bundle))
9631046

9641047
return (verifier, all_materials)
9651048

9661049

9671050
def _verify_identity(args: argparse.Namespace) -> None:
9681051
verifier, materials = _collect_verification_state(args)
9691052

970-
for file, hashed, bundle in materials:
1053+
for file_or_digest, hashed, bundle in materials:
9711054
policy_ = policy.Identity(
9721055
identity=args.cert_identity,
9731056
issuer=args.cert_oidc_issuer,
9741057
)
9751058

9761059
try:
9771060
statement = _verify_common(verifier, hashed, bundle, policy_)
978-
print(f"OK: {file}", file=sys.stderr)
1061+
print(f"OK: {file_or_digest}", file=sys.stderr)
9791062
if statement is not None:
9801063
print(statement._contents.decode())
9811064
except Error as exc:
982-
_logger.error(f"FAIL: {file}")
1065+
_logger.error(f"FAIL: {file_or_digest}")
9831066
exc.log_and_exit(_logger, args.verbose >= 1)
9841067

9851068

@@ -1020,14 +1103,14 @@ def _verify_github(args: argparse.Namespace) -> None:
10201103
policy_ = policy.AllOf(inner_policies)
10211104

10221105
verifier, materials = _collect_verification_state(args)
1023-
for file, hashed, bundle in materials:
1106+
for file_or_digest, hashed, bundle in materials:
10241107
try:
10251108
statement = _verify_common(verifier, hashed, bundle, policy_)
1026-
print(f"OK: {file}", file=sys.stderr)
1109+
print(f"OK: {file_or_digest}", file=sys.stderr)
10271110
if statement is not None:
10281111
print(statement._contents)
10291112
except Error as exc:
1030-
_logger.error(f"FAIL: {file}")
1113+
_logger.error(f"FAIL: {file_or_digest}")
10311114
exc.log_and_exit(_logger, args.verbose >= 1)
10321115

10331116

sigstore/hashes.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from sigstore.errors import Error
2626

2727

28-
class Hashed(BaseModel):
28+
class Hashed(BaseModel, frozen=True):
2929
"""
3030
Represents a hashed value.
3131
"""
@@ -55,3 +55,9 @@ def _as_prehashed(self) -> Prehashed:
5555
if self.algorithm == HashAlgorithm.SHA2_256:
5656
return Prehashed(hashes.SHA256())
5757
raise Error(f"unknown hash algorithm: {self.algorithm}")
58+
59+
def __str__(self) -> str:
60+
"""
61+
Returns a str representation of this `Hashed`.
62+
"""
63+
return f"{self.algorithm.name}:{self.digest.hex()}"

test/unit/test_hashes.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Copyright 2024 The Sigstore Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
import hashlib
15+
16+
import pytest
17+
from sigstore_protobuf_specs.dev.sigstore.common.v1 import HashAlgorithm
18+
19+
from sigstore.hashes import Hashed
20+
21+
22+
class TestHashes:
23+
@pytest.mark.parametrize(
24+
("algorithm", "digest"),
25+
[
26+
(HashAlgorithm.SHA2_256, hashlib.sha256(b"").hexdigest()),
27+
(HashAlgorithm.SHA2_384, hashlib.sha384(b"").hexdigest()),
28+
(HashAlgorithm.SHA2_512, hashlib.sha512(b"").hexdigest()),
29+
(HashAlgorithm.SHA3_256, hashlib.sha3_256(b"").hexdigest()),
30+
(HashAlgorithm.SHA3_384, hashlib.sha3_384(b"").hexdigest()),
31+
],
32+
)
33+
def test_hashed_repr(self, algorithm, digest):
34+
hashed = Hashed(algorithm=algorithm, digest=bytes.fromhex(digest))
35+
assert str(hashed) == f"{algorithm.name}:{digest}"

0 commit comments

Comments
 (0)