Skip to content

sigstore: prep verify APIs for DSSE #904

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Mar 5, 2024
22 changes: 19 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,37 @@ All versions prior to 0.9.0 are untracked.
* API: "v3" Sigstore bundles are now supported during verification
([#901](https://github.com/sigstore/sigstore-python/pull/901))

* API: `Verifier.verify(...)` can now take a `Hashed` as an input, performing
signature verification on a pre-computed hash value
([#904](https://github.com/sigstore/sigstore-python/pull/904))

### Removed

* API: `SigningResult.input_digest` has been removed; users who expect
to access the input digest may do so by inspecting the `hashedrekord`
or `dsse`-specific `SigningResult.content`
* **BREAKING API CHANGE**: `SigningResult.input_digest` has been removed;
users who expect to access the input digest may do so by inspecting the
`hashedrekord` or `dsse`-specific `SigningResult.content`
([#804](https://github.com/sigstore/sigstore-python/pull/804))

* **BREAKING API CHANGE**: `VerificationMaterials.hashed_input` has been removed
([#904](https://github.com/sigstore/sigstore-python/pull/904))

### Changed

* **BREAKING API CHANGE**: `sigstore.sign.SigningResult` has been removed
([#862](https://github.com/sigstore/sigstore-python/pull/862))

* **BREAKING API CHANGE**: The `Signer.sign(...)` API now returns a `Bundle`,
instead of a `SigningResult` ([#862](https://github.com/sigstore/sigstore-python/pull/862))

* **BREAKING API CHANGE**: `Verifier.verify(...)` now takes a `bytes | Hashed`
as its verification input, rather than implicitly receiving the input through
the `VerificationMaterials` parameter
([#904](https://github.com/sigstore/sigstore-python/pull/904))

* **BREAKING API CHANGE**: `VerificationMaterials.rekor_entry(...)` now takes
a `Hashed` parameter to convey the digest used for Rekor entry lookup
([#904](https://github.com/sigstore/sigstore-python/pull/904))

## [2.1.2]

This is a corrective release for [2.1.1].
Expand Down
29 changes: 14 additions & 15 deletions sigstore/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -856,10 +856,9 @@ def _collect_verification_state(
bundle_bytes = inputs["bundle"].read_bytes()
bundle = Bundle().from_json(bundle_bytes)

with file.open(mode="rb", buffering=0) as io:
materials = VerificationMaterials.from_bundle(
input_=io, bundle=bundle, offline=args.offline
)
materials = VerificationMaterials.from_bundle(
bundle=bundle, offline=args.offline
)
else:
# Load the signing certificate
logger.debug(f"Using certificate from: {inputs['cert']}")
Expand All @@ -870,19 +869,16 @@ def _collect_verification_state(
b64_signature = inputs["sig"].read_text()
signature = base64.b64decode(b64_signature)

with file.open(mode="rb", buffering=0) as io:
materials = VerificationMaterials(
input_=io,
cert_pem=PEMCert(cert_pem),
signature=signature,
rekor_entry=entry,
offline=args.offline,
)
materials = VerificationMaterials(
cert_pem=PEMCert(cert_pem),
signature=signature,
rekor_entry=entry,
offline=args.offline,
)

logger.debug(f"Verifying contents from: {file}")

with file.open(mode="rb", buffering=0) as io:
all_materials.append((file, materials))
all_materials.append((file, materials))

return (verifier, all_materials)

Expand Down Expand Up @@ -951,6 +947,7 @@ def _verify_identity(args: argparse.Namespace) -> None:
)

result = verifier.verify(
input_=file.read_bytes(),
materials=materials,
policy=policy_,
)
Expand Down Expand Up @@ -988,7 +985,9 @@ def _verify_github(args: argparse.Namespace) -> None:

verifier, files_with_materials = _collect_verification_state(args)
for file, materials in files_with_materials:
result = verifier.verify(materials=materials, policy=policy_)
result = verifier.verify(
input_=file.read_bytes(), materials=materials, policy=policy_
)

if result:
print(f"OK: {file}")
Expand Down
15 changes: 12 additions & 3 deletions sigstore/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,14 +175,23 @@ def key_id(key: PublicKey) -> KeyID:
return KeyID(hashlib.sha256(public_bytes).digest())


def get_digest(input_: IO[bytes] | sigstore_hashes.Hashed) -> sigstore_hashes.Hashed:
def get_digest(
input_: bytes | IO[bytes] | sigstore_hashes.Hashed,
) -> sigstore_hashes.Hashed:
"""
Compute the SHA256 digest of an input stream or, if given a `Hashed`,
return it directly.
Compute the SHA256 digest of an input stream or buffer or,
if given a `Hashed`, return it directly.
"""
if isinstance(input_, sigstore_hashes.Hashed):
return input_

# If the input is already buffered into memory, there's no point in
# going back through an I/O abstraction.
if isinstance(input_, bytes):
return sigstore_hashes.Hashed(
digest=hashlib.sha256(input_).digest(), algorithm=HashAlgorithm.SHA2_256
)

return sigstore_hashes.Hashed(
digest=sha256_streaming(input_), algorithm=HashAlgorithm.SHA2_256
)
Expand Down
32 changes: 7 additions & 25 deletions sigstore/verify/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
import logging
from dataclasses import dataclass
from textwrap import dedent
from typing import IO

import rekor_types
from cryptography.hazmat.primitives.serialization import Encoding
Expand All @@ -38,7 +37,6 @@
VerificationMaterial,
)
from sigstore_protobuf_specs.dev.sigstore.common.v1 import (
HashOutput,
LogId,
MessageSignature,
PublicKeyIdentifier,
Expand All @@ -53,7 +51,6 @@
TransparencyLogEntry,
)

from sigstore import hashes as sigstore_hashes
from sigstore._internal.rekor import RekorClient
from sigstore._utils import (
B64Str,
Expand All @@ -62,9 +59,9 @@
base64_encode_pem_cert,
cert_is_leaf,
cert_is_root_ca,
get_digest,
)
from sigstore.errors import Error
from sigstore.hashes import Hashed
from sigstore.transparency import LogEntry, LogInclusionProof

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -173,11 +170,6 @@ class VerificationMaterials:
Represents the materials needed to perform a Sigstore verification.
"""

hashed_input: sigstore_hashes.Hashed
"""
The hash of the verification input.
"""

certificate: Certificate
"""
The certificate that attests to and contains the public signing key.
Expand Down Expand Up @@ -221,7 +213,6 @@ class VerificationMaterials:
def __init__(
self,
*,
input_: IO[bytes] | sigstore_hashes.Hashed,
cert_pem: PEMCert,
signature: bytes,
offline: bool = False,
Expand All @@ -236,11 +227,8 @@ def __init__(
its proof of inclusion will not be checked. This is a slightly weaker
verification mode, as it demonstrates that an entry has been signed by
the log but not necessarily included in it.

Effect: `input_` is consumed as part of construction.
"""

self.hashed_input = get_digest(input_)
self.certificate = load_pem_x509_certificate(cert_pem.encode())
self.signature = signature

Expand All @@ -254,12 +242,10 @@ def __init__(

@classmethod
def from_bundle(
cls, *, input_: IO[bytes], bundle: Bundle, offline: bool = False
cls, *, bundle: Bundle, offline: bool = False
) -> VerificationMaterials:
"""
Create a new `VerificationMaterials` from the given Sigstore bundle.

Effect: `input_` is consumed as part of construction.
"""
try:
media_type = BundleType(bundle.media_type)
Expand Down Expand Up @@ -367,7 +353,6 @@ def from_bundle(
)

return cls(
input_=input_,
cert_pem=PEMCert(leaf_cert.public_bytes(Encoding.PEM).decode()),
signature=signature,
offline=offline,
Expand All @@ -384,9 +369,10 @@ def has_rekor_entry(self) -> bool:
"""
return self._rekor_entry is not None

def rekor_entry(self, client: RekorClient) -> LogEntry:
def rekor_entry(self, hashed_input: Hashed, client: RekorClient) -> LogEntry:
"""
Returns a `LogEntry` for the current signing materials.
Returns a `LogEntry` for the current signing materials and the given
hashed input.
"""

offline = self._offline
Expand Down Expand Up @@ -417,8 +403,8 @@ def rekor_entry(self, client: RekorClient) -> LogEntry:
),
data=rekor_types.hashedrekord.Data(
hash=rekor_types.hashedrekord.Hash(
algorithm=self.hashed_input._as_hashedrekord_algorithm(),
value=self.hashed_input.digest.hex(),
algorithm=hashed_input._as_hashedrekord_algorithm(),
value=hashed_input.digest.hex(),
),
),
),
Expand Down Expand Up @@ -510,10 +496,6 @@ def to_bundle(self) -> Bundle:
],
),
message_signature=MessageSignature(
message_digest=HashOutput(
algorithm=self.hashed_input.algorithm,
digest=self.hashed_input.digest,
),
signature=self.signature,
),
)
Expand Down
17 changes: 12 additions & 5 deletions sigstore/verify/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@
)
from sigstore._internal.set import InvalidSETError, verify_set
from sigstore._internal.trustroot import TrustedRoot
from sigstore._utils import B64Str, HexStr
from sigstore._utils import B64Str, HexStr, get_digest
from sigstore.hashes import Hashed
from sigstore.verify.models import InvalidRekorEntry as InvalidRekorEntryError
from sigstore.verify.models import RekorEntryMissing as RekorEntryMissingError
from sigstore.verify.models import (
Expand Down Expand Up @@ -150,11 +151,15 @@ def staging(cls) -> Verifier:

def verify(
self,
input_: bytes | Hashed,
materials: VerificationMaterials,
policy: VerificationPolicy,
) -> VerificationResult:
"""Public API for verifying.

`input_` is the input to verify, either as a buffer of contents or as
a prehashed `Hashed` object.

`materials` are the `VerificationMaterials` to verify.

`policy` is the `VerificationPolicy` to verify against.
Expand All @@ -163,6 +168,8 @@ def verify(
success.
"""

hashed_input = get_digest(input_)

# NOTE: The `X509Store` object currently cannot have its time reset once the `set_time`
# method been called on it. To get around this, we construct a new one for every `verify`
# call.
Expand Down Expand Up @@ -246,8 +253,8 @@ def verify(
signing_key = cast(ec.EllipticCurvePublicKey, signing_key)
signing_key.verify(
materials.signature,
materials.hashed_input.digest,
ec.ECDSA(materials.hashed_input._as_prehashed()),
hashed_input.digest,
ec.ECDSA(hashed_input._as_prehashed()),
)
except InvalidSignature:
return VerificationFailure(reason="Signature is invalid for input")
Expand All @@ -258,11 +265,11 @@ def verify(
# an offline entry), confirming its consistency with the other
# artifacts in the process.
try:
entry = materials.rekor_entry(self._rekor)
entry = materials.rekor_entry(hashed_input, self._rekor)
except RekorEntryMissingError:
return LogEntryMissing(
signature=B64Str(base64.b64encode(materials.signature).decode()),
artifact_hash=HexStr(materials.hashed_input.digest.hex()),
artifact_hash=HexStr(hashed_input.digest.hex()),
)
except InvalidRekorEntryError:
return VerificationFailure(
Expand Down
35 changes: 17 additions & 18 deletions test/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
from collections import defaultdict
from io import BytesIO
from pathlib import Path
from typing import Iterator
from typing import Callable, Iterator
from urllib.parse import urlparse

import jwt
Expand Down Expand Up @@ -153,39 +153,38 @@ def target_path(self, name: str) -> Path:


@pytest.fixture
def signing_materials():
def _signing_materials(name: str, offline: bool = False) -> VerificationMaterials:
def signing_materials() -> Callable[[str, bool], tuple[Path, VerificationMaterials]]:
def _signing_materials(
name: str, offline: bool = False
) -> tuple[Path, VerificationMaterials]:
file = _ASSETS / name
cert = _ASSETS / f"{name}.crt"
sig = _ASSETS / f"{name}.sig"

with file.open(mode="rb", buffering=0) as io:
materials = VerificationMaterials(
input_=io,
cert_pem=cert.read_text(),
signature=base64.b64decode(sig.read_text()),
offline=offline,
rekor_entry=None,
)
materials = VerificationMaterials(
cert_pem=cert.read_text(),
signature=base64.b64decode(sig.read_text()),
offline=offline,
rekor_entry=None,
)

return materials
return (file, materials)

return _signing_materials


@pytest.fixture
def signing_bundle():
def _signing_bundle(name: str, *, offline: bool = False) -> VerificationMaterials:
def _signing_bundle(
name: str, *, offline: bool = False
) -> tuple[Path, VerificationMaterials]:
file = _ASSETS / name
bundle = _ASSETS / f"{name}.sigstore"
bundle = Bundle().from_json(bundle.read_bytes())

with file.open(mode="rb", buffering=0) as io:
materials = VerificationMaterials.from_bundle(
input_=io, bundle=bundle, offline=offline
)
materials = VerificationMaterials.from_bundle(bundle=bundle, offline=offline)

return materials
return (file, materials)

return _signing_bundle

Expand Down
Loading