Skip to content
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

Offline Rekor bundle generation and verification #247

Merged
merged 40 commits into from
Nov 2, 2022
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
fd95140
_cli: flag scaffolding for offline rekor verification
woodruffw Oct 7, 2022
51d611d
_cli: more scaffolding
woodruffw Oct 7, 2022
bcf6616
Merge branch 'main' into ww/offline-rekor-bundle-verification
woodruffw Oct 11, 2022
e85b5b9
Merge branch 'main' into ww/offline-rekor-bundle-verification
woodruffw Oct 11, 2022
5aaeaf3
sigstore: refactor RekorEntry/SET verification for offline bundles
woodruffw Oct 11, 2022
6139a99
Merge branch 'main' into ww/offline-rekor-bundle-verification
woodruffw Oct 11, 2022
8c96fcd
_cli: add envvar defaults for new flags
woodruffw Oct 11, 2022
96f3af9
README: update `sigstore verify --help`
woodruffw Oct 11, 2022
98529de
_cli: handle `verify --offline` correctly
woodruffw Oct 11, 2022
d2c52c8
Merge branch 'main' into ww/offline-rekor-bundle-verification
woodruffw Oct 11, 2022
5d2e6ae
rekor/client: fix docstring
woodruffw Oct 11, 2022
988e75a
_cli: Add `rekor` suffix to offline bundle flags/options
woodruffw Oct 11, 2022
446cf31
README: update `sigstore verify`
woodruffw Oct 11, 2022
94db410
_verify: elaborate on the properties of a non-inclusion-proof verific…
woodruffw Oct 11, 2022
57d93e2
_verify: fix comment typos, reflow comments
woodruffw Oct 11, 2022
5986ade
Apply suggestions from code review
woodruffw Oct 12, 2022
286a5a4
_cli: lint
woodruffw Oct 12, 2022
b851afe
rekor/client: fix capitalization on Payload key
woodruffw Oct 13, 2022
081caad
rekor/client: fix keys
woodruffw Oct 13, 2022
34a1e4a
_cli: --rekor-bundle implies --rekor-offline
woodruffw Oct 13, 2022
caafd8d
sigstore, test: create and use a separate RekorBundle model
woodruffw Oct 13, 2022
0b0d036
sigstore, test: add offline bundle generation
woodruffw Oct 13, 2022
3abef33
sigstore: blacken
woodruffw Oct 13, 2022
8873b75
test: add an offline rekor test
woodruffw Oct 13, 2022
d689c03
_cli: tweak `--rekor-offline` language slightly
woodruffw Oct 13, 2022
64f4354
README: update `--help` blocks
woodruffw Oct 13, 2022
76b7f4c
test: unused import
woodruffw Oct 13, 2022
64370f5
sigstore: test Rekor entry's consistency against signing artifacts
woodruffw Oct 13, 2022
96bec0f
conftest: strip trailing whitespace from cert and sig
woodruffw Oct 13, 2022
76e2700
treewide: use .rekor for offline rekor bundle files
woodruffw Oct 13, 2022
df005fe
_verify: lint fixes
woodruffw Oct 13, 2022
b5eb560
_verify: more lint fixes
woodruffw Oct 13, 2022
1c3788c
README, _cli: `--rekor-offline` -> `--require-rekor-offline`
woodruffw Oct 13, 2022
44f6546
Apply suggestions from code review
woodruffw Oct 14, 2022
d1a8157
_verify: clarify comments, add a long comment explaining process
woodruffw Oct 14, 2022
e30dd3a
_verify: blacken
woodruffw Oct 14, 2022
d7c7d8e
Merge branch 'main' into ww/offline-rekor-bundle-verification
woodruffw Oct 24, 2022
4c2d4a9
Merge branch 'main' into ww/offline-rekor-bundle-verification
woodruffw Oct 26, 2022
592ec32
Merge branch 'main' into ww/offline-rekor-bundle-verification
woodruffw Oct 26, 2022
ea45d3e
_cli: add warnings when `--rekor-bundle` is used
woodruffw Nov 1, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,8 +146,9 @@ Verifying:
<!-- @begin-sigstore-verify-help@ -->
```
usage: sigstore verify [-h] [--certificate FILE] [--signature FILE]
[--cert-email EMAIL] [--cert-oidc-issuer URL]
[--staging] [--rekor-url URL]
[--bundle FILE] [--cert-email EMAIL]
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
[--cert-oidc-issuer URL] [--offline] [--staging]
[--rekor-url URL]
FILE [FILE ...]

positional arguments:
Expand All @@ -162,13 +163,17 @@ Verification inputs:
used with multiple inputs (default: None)
--signature FILE The signature to verify against; not used with
multiple inputs (default: None)
--bundle FILE The offline Rekor bundle to verify with; not used with
multiple inputs (default: None)

Extended verification options:
--cert-email EMAIL The email address to check for in the certificate's
Subject Alternative Name (default: None)
--cert-oidc-issuer URL
The OIDC issuer URL to check for in the certificate's
OIDC issuer extension (default: None)
--offline Perform offline Rekor verification using a bundle;
implied by --bundle (default: False)
woodruffw marked this conversation as resolved.
Show resolved Hide resolved

Sigstore instance options:
--staging Use sigstore's staging instances, instead of the
Expand Down
44 changes: 38 additions & 6 deletions sigstore/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# limitations under the License.

import argparse
import json
import logging
import os
import sys
Expand All @@ -33,7 +34,11 @@
STAGING_OAUTH_ISSUER,
get_identity_token,
)
from sigstore._internal.rekor.client import DEFAULT_REKOR_URL, RekorClient
from sigstore._internal.rekor.client import (
DEFAULT_REKOR_URL,
RekorClient,
RekorEntry,
)
from sigstore._sign import Signer
from sigstore._verify import (
CertificateVerificationFailure,
Expand Down Expand Up @@ -245,6 +250,13 @@ def _parser() -> argparse.ArgumentParser:
default=os.getenv("SIGSTORE_SIGNATURE"),
help="The signature to verify against; not used with multiple inputs",
)
input_options.add_argument(
"--bundle",
metavar="FILE",
type=Path,
default=os.getenv("SIGSTORE_BUNDLE"),
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
help="The offline Rekor bundle to verify with; not used with multiple inputs",
)

verification_options = verify.add_argument_group("Extended verification options")
verification_options.add_argument(
Expand All @@ -261,6 +273,12 @@ def _parser() -> argparse.ArgumentParser:
default=os.getenv("SIGSTORE_CERT_OIDC_ISSUER"),
help="The OIDC issuer URL to check for in the certificate's OIDC issuer extension",
)
verification_options.add_argument(
"--offline",
action="store_true",
default=_boolify_env("SIGSTORE_OFFLINE"),
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
help="Perform offline Rekor verification using a bundle; implied by --bundle",
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
)

instance_options = verify.add_argument_group("Sigstore instance options")
_add_shared_instance_options(instance_options)
Expand Down Expand Up @@ -403,10 +421,10 @@ def _sign(args: argparse.Namespace) -> None:


def _verify(args: argparse.Namespace) -> None:
# Fail if `--certificate` or `--signature` is specified and we have more than one input.
if (args.certificate or args.signature) and len(args.files) > 1:
# Fail if --certificate, --signature, or --bundle is specified and we have more than one input.
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
if (args.certificate or args.signature or args.bundle) and len(args.files) > 1:
args._parser.error(
"--certificate and --signature can only be used with a single input file"
"--certificate, --signature, and --bundle can only be used with a single input file"
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
)

# The converse of `sign`: we build up an expected input map and check
Expand All @@ -416,24 +434,31 @@ def _verify(args: argparse.Namespace) -> None:
if not file.is_file():
args._parser.error(f"Input must be a file: {file}")

sig, cert = args.signature, args.certificate
sig, cert, bundle = args.signature, args.certificate, args.bundle
if sig is None:
sig = file.parent / f"{file.name}.sig"
if cert is None:
cert = file.parent / f"{file.name}.crt"
if bundle is None:
bundle = file.parent / f"{file.name}.bundle"

missing = []
if not sig.is_file():
missing.append(str(sig))
if not cert.is_file():
missing.append(str(cert))
if not bundle.is_file() and args.offline:
# NOTE: We only produce errors on missing bundle files
# if the user has explicitly requested offline-only verification.
# Otherwise, we fall back on online verification.
missing.append(str(bundle))

if missing:
args._parser.error(
f"Missing verification materials for {(file)}: {', '.join(missing)}"
)

input_map[file] = {"cert": cert, "sig": sig}
input_map[file] = {"cert": cert, "sig": sig, "bundle": bundle}

if args.staging:
logger.debug("verify: staging instances requested")
Expand All @@ -456,6 +481,12 @@ def _verify(args: argparse.Namespace) -> None:
logger.debug(f"Using signature from: {inputs['sig']}")
signature = inputs["sig"].read_bytes().rstrip()

entry: Optional[RekorEntry] = None
if inputs["bundle"].is_file():
logger.debug(f"Using offline Rekor bundle from: {inputs['bundle']}")
bundle = json.loads(inputs["bundle"].read_text())
entry = RekorEntry.from_bundle(bundle)

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

result = verifier.verify(
Expand All @@ -464,6 +495,7 @@ def _verify(args: argparse.Namespace) -> None:
signature=signature,
expected_cert_email=args.cert_email,
expected_cert_oidc_issuer=args.cert_oidc_issuer,
offline_rekor_entry=entry,
)

if result:
Expand Down
9 changes: 5 additions & 4 deletions sigstore/_internal/merkle.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
import struct
from typing import List, Tuple

from sigstore._internal.rekor import RekorEntry, RekorInclusionProof
from sigstore._internal.rekor import RekorEntry


class InvalidInclusionProofError(Exception):
Expand Down Expand Up @@ -91,10 +91,11 @@ def _hash_leaf(leaf: bytes) -> bytes:
return hashlib.sha256(data).digest()


def verify_merkle_inclusion(
inclusion_proof: RekorInclusionProof, entry: RekorEntry
) -> None:
def verify_merkle_inclusion(entry: RekorEntry) -> None:
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
"""Verify the Merkle Inclusion Proof for a given Rekor entry"""
inclusion_proof = entry.inclusion_proof
if inclusion_proof is None:
raise InvalidInclusionProofError("Rekor entry has no inclusion proof")

# Figure out which subset of hashes corresponds to the inner and border nodes.
inner, border = _decomp_inclusion_proof(
Expand Down
83 changes: 78 additions & 5 deletions sigstore/_internal/rekor/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec, rsa
from pydantic import BaseModel, Field, StrictInt, StrictStr, validator
from securesystemslib.formats import encode_canonical

logger = logging.getLogger(__name__)

Expand All @@ -48,13 +49,47 @@

@dataclass(frozen=True)
class RekorEntry:
uuid: str
uuid: Optional[str]
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
"""
This entry's unique ID in the Rekor instance it was retrieved from.

For sharded Rekor deployments, IDs are unique per-shard.

Not present for `RekorEntry` instances loaded from offline bundles.
"""

body: str
"""
The base64-encoded body of the Rekor entry.
"""

integrated_time: int
"""
The UNIX time at which this entry was integrated into the Rekor log.
"""

log_id: str
"""
The log's ID (as the SHA256 hash of the DER-encoded public key for the log
at the time of entry inclusion).
"""

log_index: int
verification: dict
raw_data: dict
"""
The index of this entry within the log.
"""

inclusion_proof: Optional[RekorInclusionProof]
"""
An optional inclusion proof for this log entry.

Only present for entries retrieved from online logs.
"""

signed_entry_timestamp: str
"""
The base64-encoded Signed Entry Timestamp (SET) for this log entry.
"""

@classmethod
def from_response(cls, dict_: Dict[str, Any]) -> RekorEntry:
Expand All @@ -71,10 +106,48 @@ def from_response(cls, dict_: Dict[str, Any]) -> RekorEntry:
integrated_time=entry["integratedTime"],
log_id=entry["logID"],
log_index=entry["logIndex"],
verification=entry["verification"],
raw_data=entry,
inclusion_proof=RekorInclusionProof.parse_obj(
entry["verification"]["inclusionProof"]
),
signed_entry_timestamp=entry["verification"]["signedEntryTimestamp"],
)

@classmethod
def from_bundle(cls, dict_: Dict[str, Any]) -> RekorEntry:
"""
Creates a `RekorEntry` from an offline Rekor bundle.

See: <https://github.com/sigstore/cosign/blob/main/specs/SIGNATURE_SPEC.md#properties>
"""

payload = dict_["payload"]
return cls(
uuid=None,
body=payload["body"],
integrated_time=payload["body"],
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
log_id=payload["body"],
log_index=payload["body"],
inclusion_proof=None,
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
signed_entry_timestamp=dict_["SignedEntryTimestamp"],
)

def encode_canonical(self) -> bytes:
"""
Returns a base64-encoded, canonicalized JSON (RFC 8785) representation
of the Rekor log entry.

This encoded representation is suitable for verification against
the Signed Entry Timestamp.
"""
payload = {
"body": self.body,
"integratedTime": self.integrated_time,
"logID": self.log_id,
"logIndex": self.log_index,
}

return encode_canonical(payload).encode() # type: ignore


@dataclass(frozen=True)
class RekorLogInfo:
Expand Down
19 changes: 3 additions & 16 deletions sigstore/_internal/set.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
import cryptography.hazmat.primitives.asymmetric.ec as ec
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from securesystemslib.formats import encode_canonical

from sigstore._internal.rekor import RekorClient, RekorEntry

Expand All @@ -34,27 +33,15 @@ def verify_set(client: RekorClient, entry: RekorEntry) -> None:
"""
Verify the Signed Entry Timestamp for a given Rekor `entry` using the given `client`.
"""

# Put together the payload
#
# This involves removing any non-required fields (verification and attestation) and then
# canonicalizing the remaining JSON in accordance with IETF's RFC 8785.
raw_data = entry.raw_data.copy()
raw_data.pop("verification", None)
raw_data.pop("attestation", None)
canon_data: bytes = encode_canonical(raw_data).encode()

# Decode the SET field
signed_entry_ts: bytes = base64.b64decode(
entry.verification["signedEntryTimestamp"].encode()
)
signed_entry_ts: bytes = base64.b64decode(entry.signed_entry_timestamp)

# Validate the SET
try:
client._pubkey.verify(
signature=signed_entry_ts,
data=canon_data,
data=entry.encode_canonical(),
signature_algorithm=ec.ECDSA(hashes.SHA256()),
)
except InvalidSignature as inval_sig:
raise InvalidSetError from inval_sig
raise InvalidSetError("invalid signature") from inval_sig
Loading