Skip to content

Commit 747841e

Browse files
Add option to sign multiple artifacts with the same key and certificate (#645)
* Add option to sign multiple artifacts with the same key and cert Signed-off-by: Maya Costantini <[email protected]> * Fix linting Signed-off-by: Maya Costantini <[email protected]> * Refactor signing API into SigningContext and Signer Signed-off-by: Maya Costantini <[email protected]> * Change --single-cert option to --no-cache in README.md Signed-off-by: Maya Costantini <[email protected]> * Make _signing_cert a method instead of a property Change internal attributes to private (key and certificate) Change Generator to Iterator in with_signer context manager Implement __del__ on Signer to delete attributes when leaving the signing context scope Remove cache as an instance attribute Signed-off-by: Maya Costantini <[email protected]> * Do not store non-cached attributes Pass the full signing context to the Signer Signed-off-by: Maya Costantini <[email protected]> * Rename with_signer context manager to signer Signed-off-by: Maya Costantini <[email protected]> * Update sigstore/sign.py Signed-off-by: William Woodruff <[email protected]> * sign: remove __del__ Signed-off-by: William Woodruff <[email protected]> * sigstore: simplify OIDC token handling Leverage pyjwt's APIs more heavily Signed-off-by: William Woodruff <[email protected]> * test: fixups, disable some old tests Signed-off-by: William Woodruff <[email protected]> * test: lintage Signed-off-by: William Woodruff <[email protected]> * sigstore, test: lintage, fixups Signed-off-by: William Woodruff <[email protected]> * test: lintage Signed-off-by: William Woodruff <[email protected]> * _cli, README: label `--no-cache` as advanced Signed-off-by: William Woodruff <[email protected]> * _cli: give the flag a scary name Signed-off-by: William Woodruff <[email protected]> * sigstore, test: make `nbf` claim optional Signed-off-by: William Woodruff <[email protected]> * CHANGELOG: record changes Signed-off-by: William Woodruff <[email protected]> * README, _cli: remove flag Signed-off-by: William Woodruff <[email protected]> --------- Signed-off-by: Maya Costantini <[email protected]> Signed-off-by: William Woodruff <[email protected]> Signed-off-by: William Woodruff <[email protected]> Co-authored-by: William Woodruff <[email protected]> Co-authored-by: William Woodruff <[email protected]>
1 parent f9e3d31 commit 747841e

File tree

9 files changed

+519
-208
lines changed

9 files changed

+519
-208
lines changed

CHANGELOG.md

+8
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ All versions prior to 0.9.0 are untracked.
4141
`sigstore verify identity`, as it was during the 1.0 release series
4242
([#642](https://github.com/sigstore/sigstore-python/pull/642))
4343

44+
* API change: the `Signer` API has been broken up into `SigningContext`
45+
and `Signer`, allowing a `SigningContext` to create individual `Signer`
46+
instances that correspond to a single `IdentityToken`. This new API
47+
also enables ephemeral key and certificate reuse across multiple inputs,
48+
reducing the number of cryptographic operations and network roundtrips
49+
required when signing more than one input
50+
([#645](https://github.com/sigstore/sigstore-python/pull/645))
51+
4452
### Fixed
4553

4654
* Fixed a case where `sigstore verify` would fail to verify an otherwise valid

sigstore/_cli.py

+45-32
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@
2828

2929
from sigstore import __version__
3030
from sigstore._internal.ctfe import CTKeyring
31-
from sigstore._internal.fulcio.client import DEFAULT_FULCIO_URL, FulcioClient
31+
from sigstore._internal.fulcio.client import (
32+
DEFAULT_FULCIO_URL,
33+
ExpiredCertificate,
34+
FulcioClient,
35+
)
3236
from sigstore._internal.keyring import Keyring
3337
from sigstore._internal.rekor.client import (
3438
DEFAULT_REKOR_URL,
@@ -41,11 +45,12 @@
4145
from sigstore.oidc import (
4246
DEFAULT_OAUTH_ISSUER_URL,
4347
STAGING_OAUTH_ISSUER_URL,
48+
ExpiredIdentity,
4449
IdentityToken,
4550
Issuer,
4651
detect_credential,
4752
)
48-
from sigstore.sign import Signer
53+
from sigstore.sign import SigningContext
4954
from sigstore.transparency import LogEntry
5055
from sigstore.verify import (
5156
CertificateVerificationFailure,
@@ -620,13 +625,13 @@ def _sign(args: argparse.Namespace) -> None:
620625
"bundle": bundle,
621626
}
622627

623-
# Select the signer to use.
628+
# Select the signing context to use.
624629
if args.staging:
625630
logger.debug("sign: staging instances requested")
626-
signer = Signer.staging()
631+
signing_ctx = SigningContext.staging()
627632
args.oidc_issuer = STAGING_OAUTH_ISSUER_URL
628633
elif args.fulcio_url == DEFAULT_FULCIO_URL and args.rekor_url == DEFAULT_REKOR_URL:
629-
signer = Signer.production()
634+
signing_ctx = SigningContext.production()
630635
else:
631636
# Assume "production" keys if none are given as arguments
632637
updater = TrustUpdater.production()
@@ -642,7 +647,7 @@ def _sign(args: argparse.Namespace) -> None:
642647
ct_keyring = CTKeyring(Keyring(ctfe_keys))
643648
rekor_keyring = RekorKeyring(Keyring(rekor_keys))
644649

645-
signer = Signer(
650+
signing_ctx = SigningContext(
646651
fulcio=FulcioClient(args.fulcio_url),
647652
rekor=RekorClient(args.rekor_url, rekor_keyring, ct_keyring),
648653
)
@@ -661,38 +666,46 @@ def _sign(args: argparse.Namespace) -> None:
661666
if not identity:
662667
_die(args, "No identity token supplied or detected!")
663668

664-
for file, outputs in output_map.items():
665-
logger.debug(f"signing for {file.name}")
666-
with file.open(mode="rb", buffering=0) as io:
667-
result = signer.sign(
668-
input_=io,
669-
identity=identity,
670-
)
669+
with signing_ctx.signer(identity) as signer:
670+
for file, outputs in output_map.items():
671+
logger.debug(f"signing for {file.name}")
672+
with file.open(mode="rb", buffering=0) as io:
673+
try:
674+
result = signer.sign(input_=io)
675+
except ExpiredIdentity as exp_identity:
676+
print("Signature failed: identity token has expired")
677+
raise exp_identity
671678

672-
print("Using ephemeral certificate:")
673-
print(result.cert_pem)
679+
except ExpiredCertificate as exp_certificate:
680+
print("Signature failed: Fulcio signing certificate has expired")
681+
raise exp_certificate
674682

675-
print(f"Transparency log entry created at index: {result.log_entry.log_index}")
683+
print("Using ephemeral certificate:")
684+
print(result.cert_pem)
676685

677-
sig_output: TextIO
678-
if outputs["sig"] is not None:
679-
sig_output = outputs["sig"].open("w")
680-
else:
681-
sig_output = sys.stdout
686+
print(
687+
f"Transparency log entry created at index: {result.log_entry.log_index}"
688+
)
689+
690+
sig_output: TextIO
691+
if outputs["sig"] is not None:
692+
sig_output = outputs["sig"].open("w")
693+
else:
694+
sig_output = sys.stdout
682695

683-
print(result.b64_signature, file=sig_output)
684-
if outputs["sig"] is not None:
685-
print(f"Signature written to {outputs['sig']}")
696+
print(result.b64_signature, file=sig_output)
697+
if outputs["sig"] is not None:
698+
print(f"Signature written to {outputs['sig']}")
686699

687-
if outputs["cert"] is not None:
688-
with outputs["cert"].open(mode="w") as io:
689-
print(result.cert_pem, file=io)
690-
print(f"Certificate written to {outputs['cert']}")
700+
if outputs["cert"] is not None:
701+
with outputs["cert"].open(mode="w") as io:
702+
print(result.cert_pem, file=io)
703+
print(f"Certificate written to {outputs['cert']}")
691704

692-
if outputs["bundle"] is not None:
693-
with outputs["bundle"].open(mode="w") as io:
694-
print(result._to_bundle().to_json(), file=io)
695-
print(f"Sigstore bundle written to {outputs['bundle']}")
705+
if outputs["bundle"] is not None:
706+
with outputs["bundle"].open(mode="w") as io:
707+
print(result._to_bundle().to_json(), file=io)
708+
print(f"Sigstore bundle written to {outputs['bundle']}")
696709

697710

698711
def _collect_verification_state(

sigstore/_internal/fulcio/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@
1919

2020
from .client import (
2121
DetachedFulcioSCT,
22+
ExpiredCertificate,
2223
FulcioCertificateSigningResponse,
2324
FulcioClient,
2425
)
2526

2627
__all__ = [
2728
"DetachedFulcioSCT",
29+
"ExpiredCertificate",
2830
"FulcioCertificateSigningResponse",
2931
"FulcioClient",
3032
]

sigstore/_internal/fulcio/client.py

+4
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ def signature(self) -> bytes:
164164
SignedCertificateTimestamp.register(DetachedFulcioSCT)
165165

166166

167+
class ExpiredCertificate(Exception):
168+
"""An error raised when the Certificate is expired."""
169+
170+
167171
@dataclass(frozen=True)
168172
class FulcioCertificateSigningResponse:
169173
"""Certificate response"""

sigstore/oidc.py

+56-17
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import time
2525
import urllib.parse
2626
import webbrowser
27+
from datetime import datetime, timezone
2728
from typing import NoReturn, Optional, cast
2829

2930
import id
@@ -58,6 +59,20 @@ class _OpenIDConfiguration(BaseModel):
5859
token_endpoint: StrictStr
5960

6061

62+
# See: https://github.com/sigstore/fulcio/blob/b2186c0/pkg/config/config.go#L182-L201
63+
_KNOWN_OIDC_ISSUERS = {
64+
"https://accounts.google.com": "email",
65+
"https://oauth2.sigstore.dev/auth": "email",
66+
"https://oauth2.sigstage.dev/auth": "email",
67+
"https://token.actions.githubusercontent.com": "sub",
68+
}
69+
DEFAULT_AUDIENCE = "sigstore"
70+
71+
72+
class ExpiredIdentity(Exception):
73+
"""An error raised when an identity token is expired."""
74+
75+
6176
class IdentityToken:
6277
"""
6378
An OIDC "identity", corresponding to an underlying OIDC token with
@@ -77,22 +92,28 @@ def __init__(self, raw_token: str) -> None:
7792
# certificate binding and issuance.
7893
try:
7994
self._unverified_claims = jwt.decode(
80-
self._raw_token, options={"verify_signature": False}
95+
raw_token,
96+
options={
97+
"verify_signature": False,
98+
"verify_aud": True,
99+
"verify_iat": True,
100+
"verify_exp": True,
101+
"require": ["aud", "iat", "exp", "iss"],
102+
},
103+
audience=DEFAULT_AUDIENCE,
81104
)
82-
except jwt.InvalidTokenError as exc:
83-
raise IdentityError("invalid identity token") from exc
105+
except Exception as exc:
106+
raise IdentityError(
107+
"Identity token is malformed or missing claims"
108+
) from exc
84109

85-
self._issuer: str = self._unverified_claims.get("iss")
86-
if self._issuer is None:
87-
raise IdentityError("Identity token missing the required `iss` claim")
110+
self._iss: str = self._unverified_claims["iss"]
111+
self._nbf: int | None = self._unverified_claims.get("nbf")
112+
self._exp: int = self._unverified_claims["exp"]
88113

89-
aud = self._unverified_claims.get("aud")
90-
if aud is None:
91-
raise IdentityError("Identity token missing the required `aud` claim")
92-
if aud != _DEFAULT_AUDIENCE:
93-
raise IdentityError(
94-
f"Audience should be {_DEFAULT_AUDIENCE!r}, not {aud!r}"
95-
)
114+
# Fail early if this token isn't within its validity period.
115+
if not self.in_validity_period():
116+
raise IdentityError("Identity token is not within its validity period")
96117

97118
# When verifying the private key possession proof, Fulcio uses
98119
# different claims depending on the token's issuer.
@@ -102,15 +123,17 @@ def __init__(self, raw_token: str) -> None:
102123
if identity_claim is not None:
103124
if identity_claim not in self._unverified_claims:
104125
raise IdentityError(
105-
f"Identity token missing the required {identity_claim!r} claim"
126+
f"Identity token is missing the required {identity_claim!r} claim"
106127
)
107128

108129
self._identity = str(self._unverified_claims.get(identity_claim))
109130
else:
110131
try:
111132
self._identity = str(self._unverified_claims["sub"])
112133
except KeyError:
113-
raise IdentityError("Identity token missing the required 'sub' claim")
134+
raise IdentityError(
135+
"Identity token is missing the required 'sub' claim"
136+
)
114137

115138
# This identity token might have been retrieved directly from
116139
# an identity provider, or it might be a "federated" identity token
@@ -139,6 +162,22 @@ def __init__(self, raw_token: str) -> None:
139162

140163
self._federated_issuer = federated_issuer
141164

165+
def in_validity_period(self) -> bool:
166+
"""
167+
Returns whether or not this `Identity` is currently within its self-stated validity period.
168+
169+
NOTE: As noted in `Identity.__init__`, this is not a verifying wrapper;
170+
the check here only asserts whether the *unverified* identity's claims
171+
are within their validity period.
172+
"""
173+
174+
now = datetime.now(timezone.utc).timestamp()
175+
176+
if self._nbf is not None:
177+
return self._nbf <= now < self._exp
178+
else:
179+
return now < self._exp
180+
142181
@property
143182
def identity(self) -> str:
144183
"""
@@ -156,7 +195,7 @@ def issuer(self) -> str:
156195
"""
157196
Returns a URL identifying this `IdentityToken`'s issuer.
158197
"""
159-
return self._issuer
198+
return self._iss
160199

161200
@property
162201
def expected_certificate_subject(self) -> str:
@@ -178,7 +217,7 @@ def expected_certificate_subject(self) -> str:
178217
if self._federated_issuer is not None:
179218
return self._federated_issuer
180219

181-
return self._issuer
220+
return self.issuer
182221

183222
def __str__(self) -> str:
184223
"""

0 commit comments

Comments
 (0)