sigstore 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,8 @@
14
14
  # See the License for the specific language governing permissions and
15
15
  # limitations under the License.
16
16
 
17
+ require "protobug_in_toto_attestation_protos"
18
+
17
19
  require_relative "internal/util"
18
20
  require_relative "internal/x509"
19
21
  require_relative "models"
@@ -26,32 +28,32 @@ module Sigstore
26
28
  class Signer
27
29
  include Loggable
28
30
 
29
- def initialize(jwt:, trusted_root:)
31
+ STATEMENT_REGISTRY = Protobug::Registry.new do |registry|
32
+ InTotoAttestation::V1.register_statement_protos(registry)
33
+ end
34
+
35
+ def initialize(jwt:, trusted_root:, signing_config: nil)
30
36
  @identity_token = OIDC::IdentityToken.new(jwt)
31
37
  @trusted_root = trusted_root
38
+ @signing_config = signing_config
32
39
 
33
40
  @verifier = Verifier.for_trust_root(trust_root: @trusted_root)
34
41
  end
35
42
 
43
+ IN_TOTO_PAYLOAD_TYPE = "application/vnd.in-toto+json"
44
+
36
45
  def sign(payload)
37
- # 2) generate a keypair
38
- keypair = generate_keypair
39
- # 3) generate a CreateSigningCertificateRequest
40
- csr = generate_csr(keypair)
41
- # 4) get a cert chain from fulcio
42
- leaf = fetch_cert(csr)
43
- # 5) verify returned cert chain
44
- verify_chain(leaf)
45
- # 6) sign the payload
46
+ keypair, leaf = issue_signing_certificate
47
+
48
+ # Sign the payload, then submit a hash of the signature to the TSA and the
49
+ # signing metadata (a hashedrekord) to the transparency log.
46
50
  signature = sign_payload(payload, keypair)
47
- # 7) send hash of signature to timestamping service
48
51
  timestamp_verification_data = submit_signature_hash_to_timstamping_service(signature)
49
- # 8) submit signed metadata to transparency service
52
+
50
53
  hashed_input = Common::V1::HashOutput.new
51
54
  hashed_input.algorithm = Common::V1::HashAlgorithm::SHA2_256
52
55
  hashed_input.digest = OpenSSL::Digest("SHA256").digest(payload)
53
- tlog_entry = submit_signed_metadata_to_transparency_service(signature, leaf, hashed_input)
54
- # 9) perform verification
56
+ tlog_entry = submit_message_signature_entry(signature, leaf, hashed_input)
55
57
 
56
58
  bundle = collect_bundle(leaf, [tlog_entry], timestamp_verification_data, hashed_input, signature)
57
59
  verify(payload, bundle)
@@ -59,8 +61,39 @@ module Sigstore
59
61
  bundle
60
62
  end
61
63
 
64
+ # Sign +payload+ (an in-toto statement) as a DSSE envelope, returning a
65
+ # bundle whose content is the envelope rather than a message signature.
66
+ def sign_dsse(payload, payload_type: IN_TOTO_PAYLOAD_TYPE)
67
+ keypair, leaf = issue_signing_certificate
68
+
69
+ # DSSE signs the Pre-Authentication Encoding of the payload, not the
70
+ # payload itself.
71
+ pae = Internal::Util.dsse_pae(payload_type, payload)
72
+ signature = sign_payload(pae, keypair)
73
+ envelope = build_dsse_envelope(payload, payload_type, signature)
74
+
75
+ timestamp_verification_data = submit_signature_hash_to_timstamping_service(signature)
76
+ tlog_entry = submit_dsse_entry(envelope, leaf, pae)
77
+
78
+ bundle = collect_dsse_bundle(leaf, [tlog_entry], timestamp_verification_data, envelope)
79
+ verify_dsse_statement(payload, bundle)
80
+
81
+ bundle
82
+ end
83
+
62
84
  private
63
85
 
86
+ # Generate an ephemeral keypair, obtain a Fulcio signing certificate bound to
87
+ # the identity token, and verify the returned chain. Shared by all signing
88
+ # flows; returns [keypair, leaf_certificate].
89
+ def issue_signing_certificate
90
+ keypair = generate_keypair
91
+ csr = generate_csr(keypair)
92
+ leaf = fetch_cert(csr)
93
+ verify_chain(leaf)
94
+ [keypair, leaf]
95
+ end
96
+
64
97
  def generate_keypair
65
98
  # maybe allow configuring?
66
99
  key = OpenSSL::PKey::EC.generate("prime256v1")
@@ -104,8 +137,14 @@ module Sigstore
104
137
  }
105
138
  end
106
139
 
140
+ def fulcio_url
141
+ return @signing_config.fulcio.url if @signing_config
142
+
143
+ @trusted_root.certificate_authority_for_signing.uri
144
+ end
145
+
107
146
  def fetch_cert(csr)
108
- uri = URI.parse @trusted_root.certificate_authority_for_signing.uri
147
+ uri = URI.parse fulcio_url
109
148
  uri = URI.join(uri, "api/v2/signingCert")
110
149
  resp = Net::HTTP.post(
111
150
  uri,
@@ -188,13 +227,57 @@ module Sigstore
188
227
  key.sign("SHA256", payload)
189
228
  end
190
229
 
191
- # TODO: implement
192
- def submit_signature_hash_to_timstamping_service(_signature)
230
+ def submit_signature_hash_to_timstamping_service(signature)
193
231
  # The Signer sends a hash of the signature as the messageImprint in a TimeStampReq to the Timestamping Service and
194
232
  # receives a TimeStampResp including a `TimeStampToken`.
195
233
  # The signer MUST verify the TimeStampToken against the payload and Timestamping Service root certificate.
234
+ #
235
+ # Timestamping is driven by the signing config: a Rekor v2 entry carries no
236
+ # integrated time, so a TSA timestamp is the only trusted signing time. The
237
+ # returned tokens are verified below in #verify (against the trusted root).
238
+ tsa_urls = @signing_config&.tsa_urls || []
239
+ return nil if tsa_urls.empty?
240
+
241
+ timestamps = tsa_urls.map { |url| request_timestamp(url, signature) }
196
242
 
197
- nil
243
+ Bundle::V1::TimestampVerificationData.new.tap do |data|
244
+ data.rfc3161_timestamps = timestamps
245
+ end
246
+ end
247
+
248
+ def request_timestamp(url, signature)
249
+ # The message imprint is a hash of the signature (RFC 3161 §2.4.1); the
250
+ # Verifier binds the timestamp back to the bundle signature via this imprint.
251
+ req = OpenSSL::Timestamp::Request.new
252
+ req.algorithm = "SHA256"
253
+ req.message_imprint = OpenSSL::Digest::SHA256.digest(signature)
254
+ req.cert_requested = true
255
+ req.version = 1
256
+ req.nonce = OpenSSL::BN.rand(64)
257
+
258
+ uri = URI.parse(url)
259
+ resp = Net::HTTP.post(
260
+ uri, req.to_der,
261
+ { "Content-Type" => "application/timestamp-query", "User-Agent" => Sigstore::USER_AGENT }
262
+ )
263
+ unless resp.code == "200"
264
+ raise Error::InvalidTimestamp, "TSA #{url} returned #{resp.code} #{resp.message}\n#{resp.body}"
265
+ end
266
+
267
+ # Parse the response to fail fast on a malformed token and confirm the TSA
268
+ # actually granted a timestamp (PKIStatus GRANTED / GRANTED_WITH_MODS); a token
269
+ # is only present for those statuses. Cryptographic verification of the token is
270
+ # deferred to the verification step.
271
+ response = OpenSSL::Timestamp::Response.new(resp.body)
272
+ unless [OpenSSL::Timestamp::Response::GRANTED,
273
+ OpenSSL::Timestamp::Response::GRANTED_WITH_MODS].include?(response.status)
274
+ raise Error::InvalidTimestamp,
275
+ "TSA #{url} did not grant a timestamp (status #{response.status}): #{response.status_text.inspect}"
276
+ end
277
+
278
+ Common::V1::RFC3161SignedTimestamp.new.tap do |ts|
279
+ ts.signed_timestamp = resp.body
280
+ end
198
281
  end
199
282
 
200
283
  def build_proposed_hashed_rekord_entry(signature, cert, hashed_input)
@@ -226,17 +309,27 @@ module Sigstore
226
309
  }
227
310
  end
228
311
 
229
- def submit_signed_metadata_to_transparency_service(signature, cert, hashed_input)
230
- # The Signer chooses a format for signing metadata; this format MUST be in the supportedMetadataFormats in the
231
- # Transparency Service configuration. The Signer prepares signing metadata containing at a minimum:
232
- # * The signature.
233
- # * The payload (possibly pre-hashed; if so, the entry also includes the identifier of the hash algorithm).
234
- # * Verification material (signing certificate or verification key).
235
- # * If the verification material is a certificate, the client SHOULD upload only the signing certificate and
236
- # SHOULD NOT upload the CA certificate chain.
237
- #
238
- # The signing metadata might contain additional, application-specific metadata according to the format used.
239
- # The Signer then canonically encodes the metadata (according to the chosen format).
312
+ # Submit a hashedrekord entry for a message signature: the prehash digest of
313
+ # the artifact, signed.
314
+ #
315
+ # The Signer chooses a format for signing metadata; this format MUST be in the supportedMetadataFormats in the
316
+ # Transparency Service configuration. The Signer prepares signing metadata containing at a minimum:
317
+ # * The signature.
318
+ # * The payload (possibly pre-hashed; if so, the entry also includes the identifier of the hash algorithm).
319
+ # * Verification material (signing certificate or verification key).
320
+ # * If the verification material is a certificate, the client SHOULD upload only the signing certificate and
321
+ # SHOULD NOT upload the CA certificate chain.
322
+ #
323
+ # The signing metadata might contain additional, application-specific metadata according to the format used.
324
+ # The Signer then canonically encodes the metadata (according to the chosen format).
325
+ def submit_message_signature_entry(signature, cert, hashed_input)
326
+ # A Rekor v2 (tiled) instance speaks a different API and a different entry
327
+ # format (hashedrekord 0.0.2). When the signing config selects one, submit
328
+ # there; otherwise fall back to the v1 hashedrekord flow.
329
+ if rekor_v2?
330
+ request = build_create_entry_request(hashed_input.digest, signature, cert)
331
+ return submit_rekor_v2_entry(request)
332
+ end
240
333
 
241
334
  # TODO: allow configuring the entry kind?
242
335
  proposed_entry = build_proposed_hashed_rekord_entry(signature, cert, hashed_input)
@@ -248,11 +341,110 @@ module Sigstore
248
341
  @verifier.rekor_client.log.entries.post(proposed_entry)
249
342
  end
250
343
 
344
+ # Submit a transparency log entry for a DSSE envelope. Rekor v1 stores a
345
+ # native `dsse` entry; Rekor v2 stores a hashedrekord over the DSSE PAE (the
346
+ # signed bytes), since v2 only supports the hashedrekord type.
347
+ def submit_dsse_entry(envelope, cert, pae)
348
+ if rekor_v2?
349
+ digest = OpenSSL::Digest::SHA256.digest(pae)
350
+ request = build_create_entry_request(digest, envelope.signatures.first.sig, cert)
351
+ return submit_rekor_v2_entry(request)
352
+ end
353
+
354
+ proposed_entry = build_proposed_dsse_entry(envelope, cert)
355
+
356
+ ctlog = @trusted_root.tlog_for_signing
357
+ logger.info { "Submitting to #{ctlog.base_url}" }
358
+
359
+ @verifier.rekor_client.log.entries.post(proposed_entry)
360
+ end
361
+
362
+ def rekor_v2?
363
+ @signing_config && @signing_config.tlog.major_api_version == 2
364
+ end
365
+
366
+ def submit_rekor_v2_entry(request)
367
+ tlog_url = @signing_config.tlog.url
368
+ logger.info { "Submitting to #{tlog_url} (Rekor v2)" }
369
+
370
+ Rekor::V2Client.new(url: tlog_url).create_entry(request)
371
+ end
372
+
373
+ # A Rekor v2 CreateEntryRequest for a hashedrekord 0.0.2 entry. The request
374
+ # carries only the prehash digest and the signature + verifier; the log fills
375
+ # in the data.algorithm (derived from the verifier key details) itself.
376
+ def build_create_entry_request(digest, signature, cert)
377
+ {
378
+ "hashedRekordRequestV002" => {
379
+ "digest" => Internal::Util.base64_encode(digest),
380
+ "signature" => {
381
+ "content" => Internal::Util.base64_encode(signature),
382
+ "verifier" => {
383
+ "x509Certificate" => {
384
+ "rawBytes" => Internal::Util.base64_encode(cert.to_der)
385
+ },
386
+ "keyDetails" => key_details(cert)
387
+ }
388
+ }
389
+ }
390
+ }
391
+ end
392
+
393
+ # A Rekor v1 `dsse` 0.0.1 proposed entry: the full DSSE envelope (as JSON)
394
+ # plus the signing certificate as a verifier.
395
+ def build_proposed_dsse_entry(envelope, cert)
396
+ {
397
+ "apiVersion" => "0.0.1",
398
+ "kind" => "dsse",
399
+ "spec" => {
400
+ "proposedContent" => {
401
+ # Rekor expects the envelope as a JSON string nested in the request.
402
+ "envelope" => envelope.to_json,
403
+ "verifiers" => [Internal::Util.base64_encode(cert.to_pem)]
404
+ }
405
+ }
406
+ }
407
+ end
408
+
409
+ # The Sigstore PublicKeyDetails enum name for the leaf certificate's key,
410
+ # used as the Rekor v2 verifier key_details. Only the algorithms this signer
411
+ # can produce are mapped.
412
+ def key_details(cert)
413
+ public_key = cert.openssl.public_key
414
+ unless public_key.is_a?(OpenSSL::PKey::EC)
415
+ raise Error::Signing, "unsupported signing key type: #{public_key.class}"
416
+ end
417
+
418
+ case public_key.group.curve_name
419
+ when "prime256v1" then "PKIX_ECDSA_P256_SHA_256"
420
+ when "secp384r1" then "PKIX_ECDSA_P384_SHA_384"
421
+ when "secp521r1" then "PKIX_ECDSA_P521_SHA_512"
422
+ else
423
+ raise Error::Signing, "unsupported EC curve: #{public_key.group.curve_name}"
424
+ end
425
+ end
426
+
251
427
  def verify(artifact, bundle)
428
+ input = Verification::V1::Artifact.new.tap { |a| a.artifact = artifact }
429
+ verify_bundle(input, bundle)
430
+ end
431
+
432
+ # Self-verify a freshly-signed DSSE bundle. The in-toto statement's subject
433
+ # is matched against an artifact, so verify against the first subject's
434
+ # SHA2-256 digest (the only artifact reference available at signing time).
435
+ def verify_dsse_statement(statement, bundle)
436
+ statement = InTotoAttestation::V1::Statement.decode_json(statement, registry: STATEMENT_REGISTRY)
437
+ subject = statement.subject.find { |s| s.digest["sha256"] }
438
+ raise Error::Signing, "in-toto statement has no sha256 subject to verify against" unless subject
439
+
440
+ input = Verification::V1::Artifact.new.tap { |a| a.artifact_uri = "sha256:#{subject.digest["sha256"]}" }
441
+ verify_bundle(input, bundle)
442
+ end
443
+
444
+ def verify_bundle(artifact, bundle)
252
445
  verification_input = Verification::V1::Input.new
253
446
  verification_input.bundle = bundle
254
- verification_input.artifact = Verification::V1::Artifact.new
255
- verification_input.artifact.artifact = artifact
447
+ verification_input.artifact = artifact
256
448
 
257
449
  result = @verifier.verify(
258
450
  input: VerificationInput.new(verification_input),
@@ -267,6 +459,21 @@ module Sigstore
267
459
  end
268
460
 
269
461
  def collect_bundle(leaf_certificate, tlog_entries, timestamp_verification_data, hashed_input, signature)
462
+ collect_base_bundle(leaf_certificate, tlog_entries, timestamp_verification_data).tap do |bundle|
463
+ bundle.message_signature = Sigstore::Common::V1::MessageSignature.new.tap do |ms|
464
+ ms.message_digest = hashed_input
465
+ ms.signature = signature
466
+ end
467
+ end
468
+ end
469
+
470
+ def collect_dsse_bundle(leaf_certificate, tlog_entries, timestamp_verification_data, envelope)
471
+ collect_base_bundle(leaf_certificate, tlog_entries, timestamp_verification_data).tap do |bundle|
472
+ bundle.dsse_envelope = envelope
473
+ end
474
+ end
475
+
476
+ def collect_base_bundle(leaf_certificate, tlog_entries, timestamp_verification_data)
270
477
  bundle = Bundle::V1::Bundle.new
271
478
  bundle.media_type = BundleType::BUNDLE_0_3.media_type
272
479
  bundle.verification_material = Bundle::V1::VerificationMaterial.new
@@ -274,11 +481,15 @@ module Sigstore
274
481
  bundle.verification_material.certificate.raw_bytes = leaf_certificate.to_der
275
482
  bundle.verification_material.tlog_entries = tlog_entries
276
483
  bundle.verification_material.timestamp_verification_data = timestamp_verification_data
277
- bundle.message_signature = Sigstore::Common::V1::MessageSignature.new.tap do |ms|
278
- ms.message_digest = hashed_input
279
- ms.signature = signature
280
- end
281
484
  bundle
282
485
  end
486
+
487
+ def build_dsse_envelope(payload, payload_type, signature)
488
+ DSSE::Envelope.new.tap do |envelope|
489
+ envelope.payload = payload
490
+ envelope.payloadType = payload_type
491
+ envelope.signatures = [DSSE::Signature.new.tap { |s| s.sig = signature }]
492
+ end
493
+ end
283
494
  end
284
495
  end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright 2024 The Sigstore Authors
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require "openssl"
18
+ require "protobug_sigstore_protos"
19
+
20
+ require_relative "error"
21
+ require_relative "tuf"
22
+
23
+ module Sigstore
24
+ # Parses a SigningConfig (signingconfig.v0.2) document and selects the
25
+ # services (Fulcio CA, OIDC provider, Rekor transparency log, Timestamping
26
+ # Authority) a client should use to sign.
27
+ #
28
+ # The document is decoded through the protobuf-specs SigningConfig message
29
+ # (shipped since protobug_sigstore_protos 0.2.0). The service-selection
30
+ # algorithm mirrors sigstore-python's SigningConfig._get_valid_services: per
31
+ # kind, keep services whose major API version is supported and whose validity
32
+ # window covers now, collapse to one service per operator (highest supported
33
+ # version), then apply the ServiceConfiguration selector (ANY/EXACT/ALL).
34
+ class SigningConfig
35
+ MEDIA_TYPE = "application/vnd.dev.sigstore.signingconfig.v0.2+json"
36
+
37
+ REGISTRY = Protobug::Registry.new do |registry|
38
+ Sigstore::TrustRoot::V1.register_sigstore_trustroot_protos(registry)
39
+ end
40
+
41
+ Selector = Sigstore::TrustRoot::V1::ServiceSelector
42
+
43
+ REKOR_VERSIONS = [1, 2].freeze
44
+ TSA_VERSIONS = [1].freeze
45
+ FULCIO_VERSIONS = [1].freeze
46
+ OIDC_VERSIONS = [1].freeze
47
+
48
+ # The signing config published by the public-good (or staging) Sigstore
49
+ # instance via TUF, or nil if the repository does not publish one. Mirrors
50
+ # TrustedRoot.production/.staging.
51
+ def self.production(offline: false)
52
+ from_tuf(TUF::DEFAULT_TUF_URL, offline)
53
+ end
54
+
55
+ def self.staging(offline: false)
56
+ from_tuf(TUF::STAGING_TUF_URL, offline)
57
+ end
58
+
59
+ def self.from_tuf(url, offline)
60
+ updater = TUF::TrustUpdater.new(url, offline)
61
+ updater.refresh unless offline
62
+ from_tuf_updater(updater)
63
+ end
64
+
65
+ # Build from an already-refreshed TrustUpdater, so a caller that also needs
66
+ # the trusted root can share one updater (and one refresh).
67
+ def self.from_tuf_updater(updater)
68
+ path = updater.signing_config_path
69
+ path && from_file(path)
70
+ end
71
+
72
+ def self.from_file(path)
73
+ from_json(Gem.read_binary(path))
74
+ end
75
+
76
+ def self.from_json(contents)
77
+ new(Sigstore::TrustRoot::V1::SigningConfig.decode_json(contents, registry: REGISTRY))
78
+ rescue Protobug::Error => e
79
+ raise Error::InvalidSigningConfig, "invalid signing config: #{e.message}"
80
+ end
81
+
82
+ def initialize(config)
83
+ unless config.media_type == MEDIA_TYPE
84
+ raise Error::InvalidSigningConfig, "unsupported signing config format: #{config.media_type.inspect}"
85
+ end
86
+
87
+ @fulcios = select_services(config.ca_urls, FULCIO_VERSIONS, nil)
88
+ raise Error::InvalidSigningConfig, "No valid Fulcio CA found in signing config" if @fulcios.empty?
89
+
90
+ @oidcs = select_services(config.oidc_urls, OIDC_VERSIONS, nil)
91
+
92
+ @tlogs = select_services(config.rekor_tlog_urls, REKOR_VERSIONS, config.rekor_tlog_config,
93
+ prefer_version: preferred_rekor_major_version)
94
+ raise Error::InvalidSigningConfig, "No valid Rekor transparency log found in signing config" if @tlogs.empty?
95
+
96
+ @tsas = select_services(config.tsa_urls, TSA_VERSIONS, config.tsa_config)
97
+ end
98
+
99
+ # The Rekor transparency log to submit the signing metadata to.
100
+ def tlog
101
+ @tlogs.first
102
+ end
103
+
104
+ def fulcio
105
+ @fulcios.first
106
+ end
107
+
108
+ def oidc_url
109
+ @oidcs.first&.url
110
+ end
111
+
112
+ # The Timestamping Authority URLs to request RFC 3161 timestamps from.
113
+ def tsa_urls
114
+ @tsas.map(&:url)
115
+ end
116
+
117
+ private
118
+
119
+ # On OpenSSL builds with a broken X509::Store#time (ruby/openssl#770) an RFC 3161
120
+ # timestamp cannot be verified, and Rekor v2 entries carry no integrated time, so a
121
+ # v2 bundle could never be verified there (even the signer's own self-verification
122
+ # would fail). Prefer a Rekor v1 log in that case, when the signing config offers one,
123
+ # so signing still produces a verifiable bundle.
124
+ def preferred_rekor_major_version
125
+ return nil unless OpenSSL::X509::Store.new.instance_variable_defined?(:@time)
126
+
127
+ 1
128
+ end
129
+
130
+ def select_services(services, supported_versions, config, prefer_version: nil)
131
+ by_operator = Hash.new { |h, k| h[k] = [] }
132
+ services.each do |service|
133
+ next unless supported_versions.include?(service.major_api_version)
134
+ next unless timerange_valid?(service.valid_for)
135
+
136
+ by_operator[service.operator] << service
137
+ end
138
+
139
+ # One service per operator. Normally prefer the highest supported version; when
140
+ # +prefer_version+ is supplied and an operator offers it, pick that version instead.
141
+ result = by_operator.values.map do |op_services|
142
+ (prefer_version && op_services.find { |s| s.major_api_version == prefer_version }) ||
143
+ op_services.max_by(&:major_api_version)
144
+ end
145
+
146
+ # An absent ServiceConfiguration (or the ALL selector) imposes no count
147
+ # constraint, so every per-operator service is returned.
148
+ return result if config.nil? || config.selector == Selector::ALL
149
+
150
+ if config.selector == Selector::EXACT
151
+ count = config.count
152
+ unless count.is_a?(Integer) && count.positive?
153
+ raise Error::InvalidSigningConfig,
154
+ "EXACT selector requires a positive integer count, got #{count.inspect}"
155
+ end
156
+
157
+ # EXACT means at least `count` services must remain after filtering and
158
+ # per-operator collapsing; select the first `count` of them. Fewer than
159
+ # `count` is a misconfiguration; more is acceptable (mirrors
160
+ # sigstore-python's SigningConfig._get_valid_services).
161
+ if result.size < count
162
+ raise Error::InvalidSigningConfig, "Expected #{count} services in signing config, found #{result.size}"
163
+ end
164
+
165
+ return result.first(count)
166
+ end
167
+
168
+ result.first(1)
169
+ end
170
+
171
+ def timerange_valid?(period)
172
+ return true unless period
173
+
174
+ now = Time.now.utc
175
+ return false if period.start && now < period.start.to_time
176
+ return false if period.end && now > period.end.to_time
177
+
178
+ true
179
+ end
180
+ end
181
+ end
@@ -36,8 +36,13 @@ module Sigstore
36
36
  end
37
37
 
38
38
  def self.from_tuf(url, offline)
39
- path = TUF::TrustUpdater.new(url, offline).tap { _1.refresh unless offline }.trusted_root_path
40
- from_file(path)
39
+ from_tuf_updater(TUF::TrustUpdater.new(url, offline).tap { _1.refresh unless offline })
40
+ end
41
+
42
+ # Build from an already-refreshed TrustUpdater, so a caller that also needs
43
+ # the signing config can share one updater (and one refresh).
44
+ def self.from_tuf_updater(updater)
45
+ from_file(updater.trusted_root_path)
41
46
  end
42
47
 
43
48
  def self.from_file(path)
@@ -46,8 +51,11 @@ module Sigstore
46
51
  end
47
52
 
48
53
  def rekor_keys
54
+ # A trusted root may list more than one transparency log (e.g. a Rekor v1
55
+ # instance alongside a Rekor v2 tiled log). The keyring selects the right
56
+ # key per entry by log id, so return all of them.
49
57
  keys = tlog_keys(tlogs).to_a
50
- raise Error::InvalidBundle, "Did not find one Rekor key" if keys.size != 1
58
+ raise Error::InvalidBundle, "Did not find any Rekor keys" if keys.empty?
51
59
 
52
60
  keys
53
61
  end
@@ -87,7 +95,15 @@ module Sigstore
87
95
 
88
96
  tlogs.each do |transparency_log_instance|
89
97
  key = transparency_log_instance.public_key
90
- parsed_key = Internal::Key.from_key_details(key.key_details, key.raw_bytes)
98
+ # Verification keyring: include keys whose validity window has started, allowing
99
+ # already-expired keys so historical entries still verify, but skipping keys that
100
+ # are not yet valid. This mirrors the signing-path window check in #tlog_for_signing
101
+ # and sigstore-python's verify-purpose keyring (allow_expired: true).
102
+ next unless timerange_valid?(key.valid_for, allow_expired: true)
103
+
104
+ declared_log_id = transparency_log_instance.log_id&.key_id
105
+ key_id = declared_log_id.unpack1("H*") if declared_log_id && !declared_log_id.empty?
106
+ parsed_key = Internal::Key.from_key_details(key.key_details, key.raw_bytes, key_id:)
91
107
  yield parsed_key if parsed_key
92
108
  end
93
109
  end
data/lib/sigstore/tuf.rb CHANGED
@@ -120,6 +120,19 @@ module Sigstore
120
120
  path
121
121
  end
122
122
 
123
+ # Path to the TUF-distributed v0.2 signing config, or nil if the repository
124
+ # does not publish one (older repositories, or offline mode where it is not
125
+ # vendored). Signing always requires network access, so there is no cached
126
+ # offline fallback as there is for the trusted root.
127
+ def signing_config_path
128
+ return unless @updater
129
+
130
+ info = @updater.get_targetinfo("signing_config.v0.2.json")
131
+ return unless info
132
+
133
+ @updater.find_cached_target(info) || @updater.download_target(info)
134
+ end
135
+
123
136
  def refresh
124
137
  raise ArgumentError, "Offline mode: cannot refresh" if @offline || !@updater
125
138