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