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
15"""Sigstore based signature, signers and verifiers."""
16
17import pathlib
18from typing import Self, cast
19
20from google.protobuf import json_format
21from sigstore import dsse as sigstore_dsse
22from sigstore import models as sigstore_models
23from sigstore import oidc as sigstore_oidc
24from sigstore import sign as sigstore_signer
25from sigstore import verify as sigstore_verifier
26from typing_extensions import override
27
28from model_signing._signing import signing
29
30
31_DEFAULT_CLIENT_ID = "sigstore"
32_DEFAULT_CLIENT_SECRET = ""
33
34
35class Signature(signing.Signature):
36 """Sigstore signature support, wrapping around `sigstore_models.Bundle`."""
37
38 def __init__(self, bundle: sigstore_models.Bundle):
39 """Builds an instance of this signature.
40
41 Args:
42 bundle: the sigstore bundle (in `bundle_pb.Bundle` format).
43 """
44 self.bundle = bundle
45
46 @override
47 def write(self, path: pathlib.Path) -> None:
48 path.write_bytes(self.to_bytes())
49
50 @override
51 def to_bytes(self) -> bytes:
52 return self.bundle.to_json().encode("utf-8")
53
54 @classmethod
55 @override
56 def read(cls, path: pathlib.Path) -> Self:
57 content = path.read_text(encoding="utf-8")
58 return cls(sigstore_models.Bundle.from_json(content))
59
60
61class Signer(signing.Signer):
62 """Signing using Sigstore."""
63
64 def __init__(
65 self,
66 *,
67 oidc_issuer: str | None = None,
68 use_ambient_credentials: bool = True,
69 use_staging: bool = False,
70 identity_token: str | None = None,
71 force_oob: bool = False,
72 client_id: str | None = None,
73 client_secret: str | None = None,
74 trust_config: pathlib.Path | None = None,
75 ):
76 """Initializes Sigstore signers.
77
78 Needs to set-up a signing context to use the public goods instance and
79 machinery for getting an identity token to use in signing.
80
81 Args:
82 oidc_issuer: An optional OpenID Connect issuer to use instead of the
83 default production one. Only relevant if `use_staging = False`.
84 Default is empty, relying on the Sigstore configuration.
85 use_ambient_credentials: Use ambient credentials (also known as
86 Workload Identity). Default is True. If ambient credentials cannot
87 be used (not available, or option disabled), a flow to get signer
88 identity via OIDC will start.
89 use_staging: Use staging configurations, instead of production. This
90 is supposed to be set to True only when testing. Default is False.
91 force_oob: If True, forces an out-of-band (OOB) OAuth flow. If set,
92 the OAuth authentication will not attempt to open the default web
93 browser. Instead, it will display a URL and code for manual
94 authentication. Default is False, which means the browser will be
95 opened automatically if possible.
96 identity_token: An explicit identity token to use when signing,
97 taking precedence over any ambient credential or OAuth workflow.
98 client_id: An optional client ID to use when performing OIDC-based
99 authentication. This is typically used to identify the
100 application making the request to the OIDC provider. If not
101 provided, the default client ID configured by Sigstore will be
102 used.
103 client_secret: An optional client secret to use along with the
104 client ID when authenticating with the OIDC provider. This is
105 required for confidential clients that need to prove their
106 identity to the OIDC provider. If not provided, it is assumed
107 that the client is public or the provider does not require a
108 secret.
109 trust_config: A path to a custom trust configuration. When
110 provided, the signature verification process will rely on the
111 supplied PKI and trust configurations, instead of the default
112 Sigstore setup. If not specified, the default Sigstore
113 configuration is used.
114 """
115 if use_staging:
116 trust_config = sigstore_models.ClientTrustConfig.staging()
117 elif trust_config:
118 trust_config = sigstore_models.ClientTrustConfig.from_json(
119 trust_config.read_text()
120 )
121 else:
122 trust_config = sigstore_models.ClientTrustConfig.production()
123
124 if not oidc_issuer:
125 oidc_issuer = trust_config.signing_config.get_oidc_url()
126
127 self._oidc_issuer = oidc_issuer
128 self._issuer: sigstore_oidc.Issuer | None = None
129 self._signing_context = (
130 sigstore_signer.SigningContext.from_trust_config(trust_config)
131 )
132 self._use_ambient_credentials = use_ambient_credentials
133 self._identity_token = identity_token
134 self._force_oob = force_oob
135 self._client_id = client_id or _DEFAULT_CLIENT_ID
136 self._client_secret = client_secret or _DEFAULT_CLIENT_SECRET
137
138 def _get_identity_token(self) -> sigstore_oidc.IdentityToken:
139 """Obtains an identity token to use in signing.
140
141 The precedence matches that of sigstore-python:
142 1) Explicitly supplied identity token
143 2) Ambient credential detected in the environment, if enabled
144 3) Interactive OAuth flow
145 """
146 if self._identity_token:
147 return sigstore_oidc.IdentityToken(
148 self._identity_token, self._client_id
149 )
150 if self._use_ambient_credentials:
151 token = sigstore_oidc.detect_credential(self._client_id)
152 if token:
153 return sigstore_oidc.IdentityToken(token, self._client_id)
154
155 if self._issuer is None:
156 self._issuer = sigstore_oidc.Issuer(self._oidc_issuer)
157
158 return self._issuer.identity_token(
159 force_oob=self._force_oob,
160 client_id=self._client_id,
161 client_secret=self._client_secret,
162 )
163
164 @override
165 def sign(self, payload: signing.Payload) -> Signature:
166 # We need to convert from in-toto statement to Sigstore's DSSE
167 # version. They both contain the same contents, but there is no way
168 # to coerce one type to the other.
169 # See also: https://github.com/sigstore/sigstore-python/issues/1076
170 statement = sigstore_dsse.Statement(
171 json_format.MessageToJson(payload.statement.pb).encode("utf-8")
172 )
173
174 token = self._get_identity_token()
175 with self._signing_context.signer(token) as signer:
176 bundle = signer.sign_dsse(statement)
177
178 return Signature(bundle)
179
180
181class Verifier(signing.Verifier):
182 """Signature verification using Sigstore."""
183
184 def __init__(
185 self,
186 *,
187 identity: str,
188 oidc_issuer: str,
189 use_staging: bool = False,
190 trust_config: pathlib.Path | None = None,
191 ):
192 """Initializes Sigstore verifiers.
193
194 When verifying a signature, we also check an identity policy: the
195 certificate must belong to a given "identity", and must be issued by a
196 given OpenID Connect issuer.
197
198 Args:
199 identity: The expected identity that has signed the model.
200 oidc_issuer: The expected OpenID Connect issuer that provided the
201 certificate used for the signature.
202 use_staging: Use staging configurations, instead of production. This
203 is supposed to be set to True only when testing. Default is False.
204 trust_config: A path to a custom trust configuration. When provided,
205 the signature verification process will rely on the supplied
206 PKI and trust configurations, instead of the default Sigstore
207 setup. If not specified, the default Sigstore configuration
208 is used.
209 """
210 if trust_config:
211 trust_config = sigstore_models.ClientTrustConfig.from_json(
212 trust_config.read_text()
213 )
214 elif use_staging:
215 trust_config = sigstore_models.ClientTrustConfig.staging()
216 else:
217 trust_config = sigstore_models.ClientTrustConfig.production()
218
219 self._verifier = sigstore_verifier.Verifier(
220 trusted_root=trust_config.trusted_root
221 )
222
223 self._policy = sigstore_verifier.policy.Identity(
224 identity=identity, issuer=oidc_issuer
225 )
226
227 @override
228 def _verify_signed_content(
229 self, signature: signing.Signature
230 ) -> tuple[str, bytes]:
231 # We are guaranteed to only use the local signature type
232 signature = cast(Signature, signature)
233 bundle = signature.bundle
234 return self._verifier.verify_dsse(bundle=bundle, policy=self._policy)