Skip to content

Commit 1730a99

Browse files
woodruffwdihaydentherapper
authored
Offline Rekor bundle generation and verification (#247)
* _cli: flag scaffolding for offline rekor verification Signed-off-by: William Woodruff <william@trailofbits.com> * _cli: more scaffolding Signed-off-by: William Woodruff <william@trailofbits.com> * sigstore: refactor RekorEntry/SET verification for offline bundles Signed-off-by: William Woodruff <william@trailofbits.com> * _cli: add envvar defaults for new flags Signed-off-by: William Woodruff <william@trailofbits.com> * README: update `sigstore verify --help` Signed-off-by: William Woodruff <william@trailofbits.com> * _cli: handle `verify --offline` correctly Signed-off-by: William Woodruff <william@trailofbits.com> * rekor/client: fix docstring The returned value here is not base64-encoded. Signed-off-by: William Woodruff <william@trailofbits.com> * _cli: Add `rekor` suffix to offline bundle flags/options Signed-off-by: William Woodruff <william@trailofbits.com> * README: update `sigstore verify` Signed-off-by: William Woodruff <william@trailofbits.com> * _verify: elaborate on the properties of a non-inclusion-proof verification Signed-off-by: William Woodruff <william@trailofbits.com> * _verify: fix comment typos, reflow comments Signed-off-by: William Woodruff <william@trailofbits.com> * Apply suggestions from code review Co-authored-by: Dustin Ingram <di@users.noreply.github.com> Signed-off-by: William Woodruff <william@yossarian.net> * _cli: lint Signed-off-by: William Woodruff <william@trailofbits.com> * rekor/client: fix capitalization on Payload key Signed-off-by: William Woodruff <william@trailofbits.com> * rekor/client: fix keys Signed-off-by: William Woodruff <william@trailofbits.com> * _cli: --rekor-bundle implies --rekor-offline In other words: if a user explicitly passes a bundle filename, we never fall back on online verification. Signed-off-by: William Woodruff <william@trailofbits.com> * sigstore, test: create and use a separate RekorBundle model This makes validation a little simpler. Signed-off-by: William Woodruff <william@trailofbits.com> * sigstore, test: add offline bundle generation Signed-off-by: William Woodruff <william@trailofbits.com> * sigstore: blacken Signed-off-by: William Woodruff <william@trailofbits.com> * test: add an offline rekor test Signed-off-by: William Woodruff <william@trailofbits.com> * _cli: tweak `--rekor-offline` language slightly To emphasize that the absence of `--rekor-offline` does not always imply fully online verification. Signed-off-by: William Woodruff <william@trailofbits.com> * README: update `--help` blocks Signed-off-by: William Woodruff <william@trailofbits.com> * test: unused import Signed-off-by: William Woodruff <william@trailofbits.com> * sigstore: test Rekor entry's consistency against signing artifacts Signed-off-by: William Woodruff <william@trailofbits.com> * conftest: strip trailing whitespace from cert and sig Trailing whitespace from the signature was breaking the Rekor consistency check. Signed-off-by: William Woodruff <william@trailofbits.com> * treewide: use .rekor for offline rekor bundle files Signed-off-by: William Woodruff <william@trailofbits.com> * _verify: lint fixes Signed-off-by: William Woodruff <william@trailofbits.com> * _verify: more lint fixes Signed-off-by: William Woodruff <william@trailofbits.com> * README, _cli: `--rekor-offline` -> `--require-rekor-offline` Signed-off-by: William Woodruff <william@trailofbits.com> * Apply suggestions from code review Co-authored-by: Hayden B <hblauzvern@gmail.com> Signed-off-by: William Woodruff <william@yossarian.net> * _verify: clarify comments, add a long comment explaining process Signed-off-by: William Woodruff <william@trailofbits.com> * _verify: blacken Signed-off-by: William Woodruff <william@trailofbits.com> Signed-off-by: William Woodruff <william@trailofbits.com> Signed-off-by: William Woodruff <william@yossarian.net> Co-authored-by: Dustin Ingram <di@users.noreply.github.com> Co-authored-by: Hayden B <hblauzvern@gmail.com>
1 parent 230d9dc commit 1730a99

File tree

15 files changed

+471
-86
lines changed

15 files changed

+471
-86
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ build
1515
*.pem
1616
*.sh
1717
*.pub
18+
*.rekor
1819

1920
# Don't ignore these files when we intend to include them
2021
!sigstore/_store/*.crt
@@ -23,3 +24,4 @@ build
2324
!test/assets/*.txt
2425
!test/assets/*.crt
2526
!test/assets/*.sig
27+
!test/assets/*.rekor

README.md

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,9 @@ usage: sigstore sign [-h] [--identity-token TOKEN] [--oidc-client-id ID]
8989
[--oidc-client-secret SECRET]
9090
[--oidc-disable-ambient-providers] [--oidc-issuer URL]
9191
[--no-default-files] [--signature FILE]
92-
[--certificate FILE] [--overwrite] [--staging]
93-
[--rekor-url URL] [--fulcio-url URL] [--ctfe FILE]
94-
[--rekor-root-pubkey FILE]
92+
[--certificate FILE] [--rekor-bundle FILE] [--overwrite]
93+
[--staging] [--rekor-url URL] [--fulcio-url URL]
94+
[--ctfe FILE] [--rekor-root-pubkey FILE]
9595
FILE [FILE ...]
9696

9797
positional arguments:
@@ -115,14 +115,18 @@ OpenID Connect options:
115115
--staging) (default: https://oauth2.sigstore.dev/auth)
116116

117117
Output options:
118-
--no-default-files Don't emit the default output files ({input}.sig and
119-
{input}.crt) (default: False)
118+
--no-default-files Don't emit the default output files ({input}.sig,
119+
{input}.crt, {input}.rekor) (default: False)
120120
--signature FILE, --output-signature FILE
121121
Write a single signature to the given file; does not
122122
work with multiple input files (default: None)
123123
--certificate FILE, --output-certificate FILE
124124
Write a single certificate to the given file; does not
125125
work with multiple input files (default: None)
126+
--rekor-bundle FILE, --output-rekor-bundle FILE
127+
Write a single offline Rekor bundle to the given file;
128+
does not work with multiple input files (default:
129+
None)
126130
--overwrite Overwrite preexisting signature and certificate
127131
outputs, if present (default: False)
128132

@@ -147,7 +151,8 @@ Verifying:
147151
<!-- @begin-sigstore-verify-help@ -->
148152
```
149153
usage: sigstore verify [-h] [--certificate FILE] [--signature FILE]
150-
[--cert-email EMAIL] [--cert-oidc-issuer URL]
154+
[--rekor-bundle FILE] [--cert-email EMAIL]
155+
[--cert-oidc-issuer URL] [--require-rekor-offline]
151156
[--staging] [--rekor-url URL]
152157
FILE [FILE ...]
153158

@@ -163,13 +168,18 @@ Verification inputs:
163168
used with multiple inputs (default: None)
164169
--signature FILE The signature to verify against; not used with
165170
multiple inputs (default: None)
171+
--rekor-bundle FILE The offline Rekor bundle to verify with; not used with
172+
multiple inputs (default: None)
166173

167174
Extended verification options:
168175
--cert-email EMAIL The email address to check for in the certificate's
169176
Subject Alternative Name (default: None)
170177
--cert-oidc-issuer URL
171178
The OIDC issuer URL to check for in the certificate's
172179
OIDC issuer extension (default: None)
180+
--require-rekor-offline
181+
Require offline Rekor verification with a bundle;
182+
implied by --rekor-bundle (default: False)
173183

174184
Sigstore instance options:
175185
--staging Use sigstore's staging instances, instead of the

sigstore/_cli.py

Lines changed: 101 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,12 @@
3434
STAGING_OAUTH_ISSUER,
3535
get_identity_token,
3636
)
37-
from sigstore._internal.rekor.client import DEFAULT_REKOR_URL, RekorClient
37+
from sigstore._internal.rekor.client import (
38+
DEFAULT_REKOR_URL,
39+
RekorBundle,
40+
RekorClient,
41+
RekorEntry,
42+
)
3843
from sigstore._sign import Signer
3944
from sigstore._utils import load_pem_public_key
4045
from sigstore._verify import (
@@ -164,7 +169,7 @@ def _parser() -> argparse.ArgumentParser:
164169
"--no-default-files",
165170
action="store_true",
166171
default=_boolify_env("SIGSTORE_NO_DEFAULT_FILES"),
167-
help="Don't emit the default output files ({input}.sig and {input}.crt)",
172+
help="Don't emit the default output files ({input}.sig, {input}.crt, {input}.rekor)",
168173
)
169174
output_options.add_argument(
170175
"--signature",
@@ -186,6 +191,17 @@ def _parser() -> argparse.ArgumentParser:
186191
"Write a single certificate to the given file; does not work with multiple input files"
187192
),
188193
)
194+
output_options.add_argument(
195+
"--rekor-bundle",
196+
"--output-rekor-bundle",
197+
metavar="FILE",
198+
type=Path,
199+
default=os.getenv("SIGSTORE_OUTPUT_BUNDLE"),
200+
help=(
201+
"Write a single offline Rekor bundle to the given file; does not work with "
202+
"multiple input files"
203+
),
204+
)
189205
output_options.add_argument(
190206
"--overwrite",
191207
action="store_true",
@@ -247,6 +263,13 @@ def _parser() -> argparse.ArgumentParser:
247263
default=os.getenv("SIGSTORE_SIGNATURE"),
248264
help="The signature to verify against; not used with multiple inputs",
249265
)
266+
input_options.add_argument(
267+
"--rekor-bundle",
268+
metavar="FILE",
269+
type=Path,
270+
default=os.getenv("SIGSTORE_REKOR_BUNDLE"),
271+
help="The offline Rekor bundle to verify with; not used with multiple inputs",
272+
)
250273

251274
verification_options = verify.add_argument_group("Extended verification options")
252275
verification_options.add_argument(
@@ -263,6 +286,12 @@ def _parser() -> argparse.ArgumentParser:
263286
default=os.getenv("SIGSTORE_CERT_OIDC_ISSUER"),
264287
help="The OIDC issuer URL to check for in the certificate's OIDC issuer extension",
265288
)
289+
verification_options.add_argument(
290+
"--require-rekor-offline",
291+
action="store_true",
292+
default=_boolify_env("SIGSTORE_REQUIRE_REKOR_OFFLINE"),
293+
help="Require offline Rekor verification with a bundle; implied by --rekor-bundle",
294+
)
266295

267296
instance_options = verify.add_argument_group("Sigstore instance options")
268297
_add_shared_instance_options(instance_options)
@@ -308,20 +337,32 @@ def main() -> None:
308337

309338

310339
def _sign(args: argparse.Namespace) -> None:
311-
# `--no-default-files` has no effect on `--{signature,certificate}`, but we
340+
# `--rekor-bundle` is a temporary option, pending stabilization of the
341+
# Sigstore bundle format.
342+
if args.rekor_bundle:
343+
logger.warning(
344+
"--rekor-bundle is a temporary format, and will be removed in an "
345+
"upcoming release of sigstore-python in favor of Sigstore-style bundles"
346+
)
347+
348+
# `--no-default-files` has no effect on `--{signature,certificate,rekor-bundle}`, but we
312349
# forbid it because it indicates user confusion.
313-
if args.no_default_files and (args.signature or args.certificate):
350+
if args.no_default_files and (
351+
args.signature or args.certificate or args.rekor_bundle
352+
):
314353
args._parser.error(
315-
"--no-default-files may not be combined with --signature or "
316-
"--certificate",
354+
"--no-default-files may not be combined with --signature, "
355+
"--certificate, or --rekor-bundle",
317356
)
318357

319358
# Fail if `--signature` or `--certificate` is specified *and* we have more
320359
# than one input.
321-
if (args.signature or args.certificate) and len(args.files) > 1:
360+
if (args.signature or args.certificate or args.rekor_bundle) and len(
361+
args.files
362+
) > 1:
322363
args._parser.error(
323-
"Error: --signature and --certificate can't be used with explicit "
324-
"outputs for multiple inputs",
364+
"Error: --signature, --certificate, and --rekor-bundle can't be used "
365+
"with explicit outputs for multiple inputs",
325366
)
326367

327368
# Build up the map of inputs -> outputs ahead of any signing operations,
@@ -331,25 +372,28 @@ def _sign(args: argparse.Namespace) -> None:
331372
if not file.is_file():
332373
args._parser.error(f"Input must be a file: {file}")
333374

334-
sig, cert = args.signature, args.certificate
335-
if not sig and not cert and not args.no_default_files:
375+
sig, cert, bundle = args.signature, args.certificate, args.rekor_bundle
376+
if not sig and not cert and not bundle and not args.no_default_files:
336377
sig = file.parent / f"{file.name}.sig"
337378
cert = file.parent / f"{file.name}.crt"
379+
bundle = file.parent / f"{file.name}.rekor"
338380

339381
if not args.overwrite:
340382
extants = []
341383
if sig and sig.exists():
342384
extants.append(str(sig))
343385
if cert and cert.exists():
344386
extants.append(str(cert))
387+
if bundle and bundle.exists():
388+
extants.append(str(bundle))
345389

346390
if extants:
347391
args._parser.error(
348392
"Refusing to overwrite outputs without --overwrite: "
349393
f"{', '.join(extants)}"
350394
)
351395

352-
output_map[file] = {"cert": cert, "sig": sig}
396+
output_map[file] = {"cert": cert, "sig": sig, "bundle": bundle}
353397

354398
# Select the signer to use.
355399
if args.staging:
@@ -396,20 +440,41 @@ def _sign(args: argparse.Namespace) -> None:
396440
sig_output = sys.stdout
397441

398442
print(result.b64_signature, file=sig_output)
399-
if outputs["sig"]:
400-
print(f"Signature written to file {outputs['sig']}")
443+
if outputs["sig"] is not None:
444+
print(f"Signature written to {outputs['sig']}")
401445

402446
if outputs["cert"] is not None:
403-
cert_output = open(outputs["cert"], "w")
404-
print(result.cert_pem, file=cert_output)
405-
print(f"Certificate written to file {outputs['cert']}")
447+
with outputs["cert"].open(mode="w") as io:
448+
print(result.cert_pem, file=io)
449+
print(f"Certificate written to {outputs['cert']}")
450+
451+
if outputs["bundle"] is not None:
452+
with outputs["bundle"].open(mode="w") as io:
453+
bundle = result.log_entry.to_bundle()
454+
print(bundle.json(by_alias=True), file=io)
455+
print(f"Rekor bundle written to {outputs['bundle']}")
406456

407457

408458
def _verify(args: argparse.Namespace) -> None:
409-
# Fail if `--certificate` or `--signature` is specified and we have more than one input.
410-
if (args.certificate or args.signature) and len(args.files) > 1:
459+
# `--rekor-bundle` is a temporary option, pending stabilization of the
460+
# Sigstore bundle format.
461+
if args.rekor_bundle:
462+
logger.warning(
463+
"--rekor-bundle is a temporary format, and will be removed in an "
464+
"upcoming release of sigstore-python in favor of Sigstore-style bundles"
465+
)
466+
467+
# The presence of --rekor-bundle implies --require-rekor-offline.
468+
args.require_rekor_offline = args.require_rekor_offline or args.rekor_bundle
469+
470+
# Fail if --certificate, --signature, or --rekor-bundle is specified and we
471+
# have more than one input.
472+
if (args.certificate or args.signature or args.rekor_bundle) and len(
473+
args.files
474+
) > 1:
411475
args._parser.error(
412-
"--certificate and --signature can only be used with a single input file"
476+
"--certificate, --signature, and --rekor-bundle can only be used "
477+
"with a single input file"
413478
)
414479

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

422-
sig, cert = args.signature, args.certificate
487+
sig, cert, bundle = args.signature, args.certificate, args.rekor_bundle
423488
if sig is None:
424489
sig = file.parent / f"{file.name}.sig"
425490
if cert is None:
426491
cert = file.parent / f"{file.name}.crt"
492+
if bundle is None:
493+
bundle = file.parent / f"{file.name}.rekor"
427494

428495
missing = []
429496
if not sig.is_file():
430497
missing.append(str(sig))
431498
if not cert.is_file():
432499
missing.append(str(cert))
500+
if not bundle.is_file() and args.require_rekor_offline:
501+
# NOTE: We only produce errors on missing bundle files
502+
# if the user has explicitly requested offline-only verification.
503+
# Otherwise, we fall back on online verification.
504+
missing.append(str(bundle))
433505

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

439-
input_map[file] = {"cert": cert, "sig": sig}
511+
input_map[file] = {"cert": cert, "sig": sig, "bundle": bundle}
440512

441513
if args.staging:
442514
logger.debug("verify: staging instances requested")
@@ -459,6 +531,12 @@ def _verify(args: argparse.Namespace) -> None:
459531
logger.debug(f"Using signature from: {inputs['sig']}")
460532
signature = inputs["sig"].read_bytes().rstrip()
461533

534+
entry: Optional[RekorEntry] = None
535+
if inputs["bundle"].is_file():
536+
logger.debug(f"Using offline Rekor bundle from: {inputs['bundle']}")
537+
bundle = RekorBundle.parse_file(inputs["bundle"])
538+
entry = bundle.to_entry()
539+
462540
logger.debug(f"Verifying contents from: {file}")
463541

464542
result = verifier.verify(
@@ -467,6 +545,7 @@ def _verify(args: argparse.Namespace) -> None:
467545
signature=signature,
468546
expected_cert_email=args.cert_email,
469547
expected_cert_oidc_issuer=args.cert_oidc_issuer,
548+
offline_rekor_entry=entry,
470549
)
471550

472551
if result:

sigstore/_internal/merkle.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import struct
2727
from typing import List, Tuple
2828

29-
from sigstore._internal.rekor import RekorEntry, RekorInclusionProof
29+
from sigstore._internal.rekor import RekorEntry
3030

3131

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

9393

94-
def verify_merkle_inclusion(
95-
inclusion_proof: RekorInclusionProof, entry: RekorEntry
96-
) -> None:
94+
def verify_merkle_inclusion(entry: RekorEntry) -> None:
9795
"""Verify the Merkle Inclusion Proof for a given Rekor entry"""
96+
inclusion_proof = entry.inclusion_proof
97+
if inclusion_proof is None:
98+
raise InvalidInclusionProofError("Rekor entry has no inclusion proof")
9899

99100
# Figure out which subset of hashes corresponds to the inner and border nodes.
100101
inner, border = _decomp_inclusion_proof(

0 commit comments

Comments
 (0)