sigstore 0.2.2 → 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,43 +14,46 @@
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"
20
22
  require_relative "oidc"
21
23
  require_relative "policy"
22
24
  require_relative "verifier"
25
+ require_relative "version"
23
26
 
24
27
  module Sigstore
25
28
  class Signer
26
29
  include Loggable
27
30
 
28
- 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)
29
36
  @identity_token = OIDC::IdentityToken.new(jwt)
30
37
  @trusted_root = trusted_root
38
+ @signing_config = signing_config
31
39
 
32
40
  @verifier = Verifier.for_trust_root(trust_root: @trusted_root)
33
41
  end
34
42
 
43
+ IN_TOTO_PAYLOAD_TYPE = "application/vnd.in-toto+json"
44
+
35
45
  def sign(payload)
36
- # 2) generate a keypair
37
- keypair = generate_keypair
38
- # 3) generate a CreateSigningCertificateRequest
39
- csr = generate_csr(keypair)
40
- # 4) get a cert chain from fulcio
41
- leaf = fetch_cert(csr)
42
- # 5) verify returned cert chain
43
- verify_chain(leaf)
44
- # 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.
45
50
  signature = sign_payload(payload, keypair)
46
- # 7) send hash of signature to timestamping service
47
51
  timestamp_verification_data = submit_signature_hash_to_timstamping_service(signature)
48
- # 8) submit signed metadata to transparency service
52
+
49
53
  hashed_input = Common::V1::HashOutput.new
50
54
  hashed_input.algorithm = Common::V1::HashAlgorithm::SHA2_256
51
55
  hashed_input.digest = OpenSSL::Digest("SHA256").digest(payload)
52
- tlog_entry = submit_signed_metadata_to_transparency_service(signature, leaf, hashed_input)
53
- # 9) perform verification
56
+ tlog_entry = submit_message_signature_entry(signature, leaf, hashed_input)
54
57
 
55
58
  bundle = collect_bundle(leaf, [tlog_entry], timestamp_verification_data, hashed_input, signature)
56
59
  verify(payload, bundle)
@@ -58,8 +61,39 @@ module Sigstore
58
61
  bundle
59
62
  end
60
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
+
61
84
  private
62
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
+
63
97
  def generate_keypair
64
98
  # maybe allow configuring?
65
99
  key = OpenSSL::PKey::EC.generate("prime256v1")
@@ -103,13 +137,19 @@ module Sigstore
103
137
  }
104
138
  end
105
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
+
106
146
  def fetch_cert(csr)
107
- uri = URI.parse @trusted_root.certificate_authority_for_signing.uri
147
+ uri = URI.parse fulcio_url
108
148
  uri = URI.join(uri, "api/v2/signingCert")
109
149
  resp = Net::HTTP.post(
110
150
  uri,
111
151
  JSON.dump(csr),
112
- { "Content-Type" => "application/json" }
152
+ { "Content-Type" => "application/json", "User-Agent" => Sigstore::USER_AGENT }
113
153
  )
114
154
 
115
155
  unless resp.code == "200"
@@ -187,13 +227,57 @@ module Sigstore
187
227
  key.sign("SHA256", payload)
188
228
  end
189
229
 
190
- # TODO: implement
191
- def submit_signature_hash_to_timstamping_service(_signature)
230
+ def submit_signature_hash_to_timstamping_service(signature)
192
231
  # The Signer sends a hash of the signature as the messageImprint in a TimeStampReq to the Timestamping Service and
193
232
  # receives a TimeStampResp including a `TimeStampToken`.
194
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) }
195
242
 
196
- 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
197
281
  end
198
282
 
199
283
  def build_proposed_hashed_rekord_entry(signature, cert, hashed_input)
@@ -225,17 +309,27 @@ module Sigstore
225
309
  }
226
310
  end
227
311
 
228
- def submit_signed_metadata_to_transparency_service(signature, cert, hashed_input)
229
- # The Signer chooses a format for signing metadata; this format MUST be in the supportedMetadataFormats in the
230
- # Transparency Service configuration. The Signer prepares signing metadata containing at a minimum:
231
- # * The signature.
232
- # * The payload (possibly pre-hashed; if so, the entry also includes the identifier of the hash algorithm).
233
- # * Verification material (signing certificate or verification key).
234
- # * If the verification material is a certificate, the client SHOULD upload only the signing certificate and
235
- # SHOULD NOT upload the CA certificate chain.
236
- #
237
- # The signing metadata might contain additional, application-specific metadata according to the format used.
238
- # 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
239
333
 
240
334
  # TODO: allow configuring the entry kind?
241
335
  proposed_entry = build_proposed_hashed_rekord_entry(signature, cert, hashed_input)
@@ -247,11 +341,110 @@ module Sigstore
247
341
  @verifier.rekor_client.log.entries.post(proposed_entry)
248
342
  end
249
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
+
250
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)
251
445
  verification_input = Verification::V1::Input.new
252
446
  verification_input.bundle = bundle
253
- verification_input.artifact = Verification::V1::Artifact.new
254
- verification_input.artifact.artifact = artifact
447
+ verification_input.artifact = artifact
255
448
 
256
449
  result = @verifier.verify(
257
450
  input: VerificationInput.new(verification_input),
@@ -266,6 +459,21 @@ module Sigstore
266
459
  end
267
460
 
268
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)
269
477
  bundle = Bundle::V1::Bundle.new
270
478
  bundle.media_type = BundleType::BUNDLE_0_3.media_type
271
479
  bundle.verification_material = Bundle::V1::VerificationMaterial.new
@@ -273,11 +481,15 @@ module Sigstore
273
481
  bundle.verification_material.certificate.raw_bytes = leaf_certificate.to_der
274
482
  bundle.verification_material.tlog_entries = tlog_entries
275
483
  bundle.verification_material.timestamp_verification_data = timestamp_verification_data
276
- bundle.message_signature = Sigstore::Common::V1::MessageSignature.new.tap do |ms|
277
- ms.message_digest = hashed_input
278
- ms.signature = signature
279
- end
280
484
  bundle
281
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
282
494
  end
283
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
@@ -19,6 +19,7 @@ require "tempfile"
19
19
  require "uri"
20
20
  require "net/http"
21
21
  require "rubygems/remote_fetcher"
22
+ require_relative "version"
22
23
 
23
24
  module Sigstore
24
25
  module TUF
@@ -119,6 +120,19 @@ module Sigstore
119
120
  path
120
121
  end
121
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
+
122
136
  def refresh
123
137
  raise ArgumentError, "Offline mode: cannot refresh" if @offline || !@updater
124
138
 
@@ -133,8 +147,8 @@ module Sigstore
133
147
 
134
148
  fetcher = Gem::RemoteFetcher.fetcher
135
149
  begin
136
- response = fetcher.request(uri, Net::HTTP::Get, nil) do
137
- nil
150
+ response = fetcher.request(uri, Net::HTTP::Get, nil) do |req|
151
+ req["User-Agent"] = Sigstore::USER_AGENT
138
152
  end
139
153
  response.uri = uri
140
154
  case response