1"""Signer implementation for project sigstore."""
2
3from __future__ import annotations
4
5import json
6import logging
7from typing import Any
8from urllib import parse
9
10from securesystemslib.exceptions import (
11 UnsupportedLibraryError,
12 UnverifiedSignatureError,
13 VerificationError,
14)
15from securesystemslib.signer._signer import (
16 Key,
17 SecretsHandler,
18 Signature,
19 Signer,
20)
21from securesystemslib.signer._utils import compute_default_keyid
22
23IMPORT_ERROR = "sigstore library required to use 'sigstore-oidc' keys"
24
25logger = logging.getLogger(__name__)
26
27
28class SigstoreKey(Key):
29 """Sigstore verifier.
30
31 NOTE: The Sigstore key and signature serialization formats are not yet
32 considered stable in securesystemslib. They may change in future releases
33 and may not be supported by other implementations.
34 """
35
36 DEFAULT_KEY_TYPE = "sigstore-oidc"
37 DEFAULT_SCHEME = "Fulcio"
38
39 def __init__(
40 self,
41 keyid: str,
42 keytype: str,
43 scheme: str,
44 keyval: dict[str, Any],
45 unrecognized_fields: dict[str, Any] | None = None,
46 ):
47 for content in ["identity", "issuer"]:
48 if content not in keyval or not isinstance(keyval[content], str):
49 raise ValueError(f"{content} string required for scheme {scheme}")
50 super().__init__(keyid, keytype, scheme, keyval, unrecognized_fields)
51
52 @classmethod
53 def from_dict(cls, keyid: str, key_dict: dict[str, Any]) -> SigstoreKey:
54 keytype, scheme, keyval = cls._from_dict(key_dict)
55 return cls(keyid, keytype, scheme, keyval, key_dict)
56
57 def to_dict(self) -> dict:
58 return self._to_dict()
59
60 def verify_signature(self, signature: Signature, data: bytes) -> None:
61 try:
62 from sigstore.errors import VerificationError as SigstoreVerifyError
63 from sigstore.models import Bundle
64 from sigstore.verify import Verifier
65 from sigstore.verify.policy import Identity
66 except ImportError as e:
67 raise VerificationError(IMPORT_ERROR) from e
68
69 try:
70 verifier = Verifier.production()
71 identity = Identity(
72 identity=self.keyval["identity"], issuer=self.keyval["issuer"]
73 )
74 bundle_data = signature.unrecognized_fields["bundle"]
75 bundle = Bundle.from_json(json.dumps(bundle_data))
76
77 verifier.verify_artifact(data, bundle, identity)
78
79 except SigstoreVerifyError as e:
80 logger.info(
81 "Key %s failed to verify sig: %s",
82 self.keyid,
83 e,
84 )
85 raise UnverifiedSignatureError(
86 f"Failed to verify signature by {self.keyid}"
87 ) from e
88 except Exception as e:
89 logger.info("Key %s failed to verify sig: %s", self.keyid, str(e))
90 raise VerificationError(
91 f"Unknown failure to verify signature by {self.keyid}"
92 ) from e
93
94
95class SigstoreSigner(Signer):
96 """Sigstore signer.
97
98 NOTE: The Sigstore key and signature serialization formats are not yet
99 considered stable in securesystemslib. They may change in future releases
100 and may not be supported by other implementations.
101
102 All signers should be instantiated with ``Signer.from_priv_key_uri()``.
103 Unstable ``SigstoreSigner`` currently requires opt-in via
104 ``securesystemslib.signer.SIGNER_FOR_URI_SCHEME``.
105
106 Usage::
107
108 identity = "luk.puehringer@gmail.com" # change, unless you know pw
109 issuer = "https://github.com/login/oauth"
110
111 # Create signer URI and public key for identity and issuer
112 uri, public_key = SigstoreSigner.import_(identity, issuer, ambient=False)
113
114 # Load signer from URI -- requires browser login with GitHub
115 signer = SigstoreSigner.from_priv_key_uri(uri, public_key)
116
117 # Sign with signer and verify public key
118 signature = signer.sign(b"data")
119 public_key.verify_signature(signature, b"data")
120
121 The private key URI scheme is "sigstore:?<PARAMS>", where PARAMS is
122 optional and toggles ambient credential usage. Example URIs:
123
124 * "sigstore:":
125 Sign with ambient credentials.
126 * "sigstore:?ambient=false":
127 Sign with OAuth2 + OpenID via browser login.
128
129 Arguments:
130 token: The OIDC identity token used for signing.
131 public_key: The related public key instance.
132
133 Raises:
134 UnsupportedLibraryError: sigstore library not found.
135 """
136
137 SCHEME = "sigstore"
138
139 def __init__(self, token: Any, public_key: Key):
140 self._public_key = public_key
141 # token is of type sigstore.oidc.IdentityToken but the module should be usable
142 # without sigstore so it's not annotated
143 self._token = token
144
145 @property
146 def public_key(self) -> Key:
147 return self._public_key
148
149 @classmethod
150 def from_priv_key_uri(
151 cls,
152 priv_key_uri: str,
153 public_key: Key,
154 secrets_handler: SecretsHandler | None = None,
155 ) -> SigstoreSigner:
156 try:
157 from sigstore.oidc import IdentityToken, Issuer, detect_credential
158 except ImportError as e:
159 raise UnsupportedLibraryError(IMPORT_ERROR) from e
160
161 if not isinstance(public_key, SigstoreKey):
162 raise ValueError(f"expected SigstoreKey for {priv_key_uri}")
163
164 uri = parse.urlparse(priv_key_uri)
165
166 if uri.scheme != cls.SCHEME:
167 raise ValueError(f"SigstoreSigner does not support {priv_key_uri}")
168
169 params = dict(parse.parse_qsl(uri.query))
170 ambient = params.get("ambient", "true") == "true"
171
172 if not ambient:
173 # TODO: Restrict oauth flow to use identity/issuer from public_key
174 # TODO: Use secrets_handler for identity_token() secret arg
175 token = Issuer.production().identity_token()
176 else:
177 credential = detect_credential()
178 if not credential:
179 raise RuntimeError("Failed to detect Sigstore credentials")
180 token = IdentityToken(credential)
181
182 key_identity = public_key.keyval["identity"]
183 key_issuer = public_key.keyval["issuer"]
184 if key_issuer != token.federated_issuer:
185 raise ValueError(
186 f"Signer identity issuer {token.federated_issuer} "
187 f"did not match key: {key_issuer}"
188 )
189 # TODO: should check ambient identity too: unfortunately IdentityToken does
190 # not provide access to the expected identity value (cert SAN) in ambient case
191 if not ambient and key_identity != token.identity:
192 raise ValueError(
193 f"Signer identity {token.identity} did not match key: {key_identity}"
194 )
195
196 return cls(token, public_key)
197
198 @classmethod
199 def _get_uri(cls, ambient: bool) -> str:
200 return f"{cls.SCHEME}:{'' if ambient else '?ambient=false'}"
201
202 @classmethod
203 def import_(
204 cls, identity: str, issuer: str, ambient: bool = True
205 ) -> tuple[str, SigstoreKey]:
206 """Create public key and signer URI.
207
208 Returns a private key URI (for Signer.from_priv_key_uri()) and a public
209 key. import_() should be called once and the returned URI and public
210 key should be stored for later use.
211
212 Arguments:
213 identity: The OIDC identity to use when verifying a signature.
214 issuer: The OIDC issuer to use when verifying a signature.
215 ambient: Toggle usage of ambient credentials in returned URI.
216 """
217 keytype = SigstoreKey.DEFAULT_KEY_TYPE
218 scheme = SigstoreKey.DEFAULT_SCHEME
219 keyval = {"identity": identity, "issuer": issuer}
220 keyid = compute_default_keyid(keytype, scheme, keyval)
221 key = SigstoreKey(keyid, keytype, scheme, keyval)
222 uri = cls._get_uri(ambient)
223
224 return uri, key
225
226 @classmethod
227 def import_via_auth(cls) -> tuple[str, SigstoreKey]:
228 """Create public key and signer URI by interactive authentication
229
230 Returns a private key URI (for Signer.from_priv_key_uri()) and a public
231 key. This method always uses the interactive authentication.
232 """
233 try:
234 from sigstore.oidc import Issuer
235 except ImportError as e:
236 raise UnsupportedLibraryError(IMPORT_ERROR) from e
237
238 # authenticate to get the identity and issuer
239 token = Issuer.production().identity_token()
240 return cls.import_(token.identity, token.federated_issuer, False)
241
242 def sign(self, payload: bytes) -> Signature:
243 """Signs payload using the OIDC token on the signer instance.
244
245 Arguments:
246 payload: bytes to be signed.
247
248 Raises:
249 Various errors from sigstore-python.
250
251 Returns:
252 Signature.
253
254 NOTE: The relevant data is in `unrecognized_fields["bundle"]`.
255
256 """
257 try:
258 from sigstore.sign import SigningContext
259 except ImportError as e:
260 raise UnsupportedLibraryError(IMPORT_ERROR) from e
261
262 context = SigningContext.production()
263 with context.signer(self._token) as sigstore_signer:
264 bundle = sigstore_signer.sign_artifact(payload)
265 # We want to access the actual signature, see
266 # https://github.com/sigstore/protobuf-specs/blob/main/protos/sigstore_bundle.proto
267 bundle_json = json.loads(bundle.to_json())
268 return Signature(
269 self.public_key.keyid,
270 bundle_json["messageSignature"]["signature"],
271 {"bundle": bundle_json},
272 )
273
274 @classmethod
275 def import_github_actions(
276 cls, project: str, workflow_path: str, ref: str | None = "refs/heads/main"
277 ) -> tuple[str, SigstoreKey]:
278 """Convenience method to build identity and issuer string for import_() from
279 GitHub project and workflow path.
280
281 Args:
282 project: GitHub project name (example:
283 "secure-systems-lab/securesystemslib")
284 workflow_path: GitHub workflow path (example:
285 ".github/workflows/online-sign.yml")
286 ref: optional GitHub ref, defaults to refs/heads/main
287
288 Returns:
289 uri: string
290 key: SigstoreKey
291
292 """
293 identity = f"https://github.com/{project}/{workflow_path}@{ref}"
294 issuer = "https://token.actions.githubusercontent.com"
295 uri, key = cls.import_(identity, issuer)
296
297 return uri, key