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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +63 -0
- data/data/_store/prod/root.json +29 -53
- data/data/_store/prod/trusted_root.json +25 -13
- data/data/_store/staging/root.json +4 -4
- data/data/_store/staging/trusted_root.json +155 -7
- data/lib/sigstore/error.rb +4 -0
- data/lib/sigstore/internal/key.rb +83 -18
- data/lib/sigstore/internal/merkle.rb +2 -2
- data/lib/sigstore/internal/util.rb +7 -0
- data/lib/sigstore/internal/x509.rb +7 -7
- data/lib/sigstore/models.rb +56 -16
- data/lib/sigstore/oidc.rb +3 -0
- data/lib/sigstore/policy.rb +8 -0
- data/lib/sigstore/rekor/checkpoint.rb +21 -11
- data/lib/sigstore/rekor/client.rb +38 -2
- data/lib/sigstore/signer.rb +248 -36
- data/lib/sigstore/signing_config.rb +181 -0
- data/lib/sigstore/trusted_root.rb +20 -4
- data/lib/sigstore/tuf.rb +16 -2
- data/lib/sigstore/verifier.rb +283 -73
- data/lib/sigstore/version.rb +2 -1
- data/lib/sigstore.rb +1 -0
- metadata +19 -4
data/lib/sigstore/signer.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
#
|
|
39
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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 =
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|