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.
@@ -21,7 +21,7 @@ module Sigstore
21
21
  class Key
22
22
  include Loggable
23
23
 
24
- def self.from_key_details(key_details, key_bytes)
24
+ def self.from_key_details(key_details, key_bytes, key_id: nil)
25
25
  case key_details
26
26
  when Common::V1::PublicKeyDetails::PKIX_ECDSA_P256_SHA_256
27
27
  key_type = "ecdsa"
@@ -29,15 +29,34 @@ module Sigstore
29
29
  when Common::V1::PublicKeyDetails::PKCS1_RSA_PKCS1V5
30
30
  key_type = "rsa"
31
31
  key_schema = "rsa-pkcs1v15-sha256"
32
+ when Common::V1::PublicKeyDetails::PKIX_ED25519
33
+ # Rekor v2 (tiled) logs sign checkpoints with an Ed25519 key. The raw_bytes
34
+ # are a DER-encoded SubjectPublicKeyInfo, not the bare 32-byte key. The log id
35
+ # is a C2SP signed-note key hash over the log's name, not a digest of the key
36
+ # bytes, so it cannot be recomputed from the key alone; the trusted root must
37
+ # declare it. Without it the key could never match a real entry, so refuse to
38
+ # register an unusable key.
39
+ unless key_id
40
+ raise Error::MissingLogId,
41
+ "Ed25519 (Rekor v2) key has no declared log id; the trusted root must provide log_id.key_id"
42
+ end
43
+
44
+ return ED25519.new("ed25519", "ed25519", ED25519.public_key_from_spki_der(key_bytes), key_id:)
32
45
  else
33
46
  # Skip unrecognized key types instead of raising an error.
34
47
  # This allows the library to work with newer trusted roots that include
35
- # key types we don't yet support (e.g., PKIX_ED25519 for Rekor v2).
48
+ # key types we don't yet support.
36
49
  logger.warn { "Skipping unrecognized key type: #{key_details}" }
37
50
  return nil
38
51
  end
39
52
 
40
- read(key_type, key_schema, key_bytes, key_id: OpenSSL::Digest::SHA256.hexdigest(key_bytes))
53
+ # The transparency log declares its own log id in the trusted root; prefer it so
54
+ # the keyring is keyed the same way entries reference the log. For ECDSA/RSA keys
55
+ # the log id is a plain SHA-256 of the key bytes, so it can be recomputed when the
56
+ # trusted root omits it.
57
+ key_id ||= OpenSSL::Digest::SHA256.hexdigest(key_bytes)
58
+
59
+ read(key_type, key_schema, key_bytes, key_id:)
41
60
  end
42
61
 
43
62
  def self.read(key_type, schema, key_bytes, key_id: nil)
@@ -141,8 +160,27 @@ module Sigstore
141
160
  end
142
161
 
143
162
  class ED25519 < Key
163
+ # jruby-openssl cannot parse or verify Ed25519 keys, so on JRuby we hold a
164
+ # java.security.PublicKey instead of an OpenSSL::PKey and route loading and
165
+ # verification through the JDK (mirroring the java.security path in X509).
166
+ JAVA_ED25519 = RUBY_ENGINE == "jruby"
167
+
168
+ # Load an Ed25519 public key from a DER-encoded SubjectPublicKeyInfo.
169
+ def self.public_key_from_spki_der(der)
170
+ if JAVA_ED25519
171
+ spec = java.security.spec.X509EncodedKeySpec.new(der.to_java_bytes)
172
+ java.security.KeyFactory.getInstance("Ed25519").generatePublic(spec)
173
+ else
174
+ OpenSSL::PKey.read(der)
175
+ end
176
+ end
177
+
178
+ # +raw+ is the bare 32-byte public key, not a SubjectPublicKeyInfo.
144
179
  def self.pkey_from_der(raw)
145
- if OpenSSL::PKey.respond_to?(:new_raw_public_key)
180
+ if JAVA_ED25519
181
+ # Wrap the raw key in the fixed Ed25519 SubjectPublicKeyInfo prefix.
182
+ public_key_from_spki_der(["302a300506032b6570032100"].pack("H*") + raw)
183
+ elsif OpenSSL::PKey.respond_to?(:new_raw_public_key)
146
184
  OpenSSL::PKey.new_raw_public_key("ed25519", raw)
147
185
  else
148
186
  pem = <<~PEM
@@ -156,27 +194,54 @@ module Sigstore
156
194
 
157
195
  def initialize(...)
158
196
  super
159
- unless @key_type == "ed25519"
160
- raise ArgumentError,
161
- "key_type must be ed25519, given #{@key_type}"
162
- end
163
- unless @key.is_a?(OpenSSL::PKey::PKey) && @key.oid == "ED25519"
164
- raise ArgumentError,
165
- "key must be an OpenSSL::PKey::PKey with oid ED25519, is #{@key.inspect}"
166
- end
167
- raise ArgumentError, "schema must be #{schema}" unless @schema == schema
197
+ raise ArgumentError, "key_type must be ed25519, given #{@key_type}" unless @key_type == "ed25519"
168
198
 
169
- case @schema
170
- when "ed25519"
171
- # supported
172
- else
173
- raise ArgumentError, "Unsupported schema #{schema}"
199
+ if JAVA_ED25519
200
+ unless @key.respond_to?(:getAlgorithm) && %w[Ed25519 EdDSA].include?(@key.getAlgorithm)
201
+ raise ArgumentError, "key must be a java Ed25519 PublicKey, is #{@key.inspect}"
202
+ end
203
+ elsif !(@key.is_a?(OpenSSL::PKey::PKey) && @key.oid == "ED25519")
204
+ raise ArgumentError, "key must be an OpenSSL::PKey::PKey with oid ED25519, is #{@key.inspect}"
174
205
  end
206
+
207
+ raise ArgumentError, "Unsupported schema #{schema}" unless @schema == "ed25519"
175
208
  end
176
209
 
177
210
  def verify(_algo, signature, data)
211
+ return java_verify(signature, data) if JAVA_ED25519
212
+
178
213
  super(nil, signature, data)
179
214
  end
215
+
216
+ # Ed25519 keys do not implement #to_der; the SubjectPublicKeyInfo DER is the
217
+ # public encoding.
218
+ def to_der
219
+ return String.from_java_bytes(@key.getEncoded).b if JAVA_ED25519
220
+
221
+ @key.public_to_der
222
+ end
223
+
224
+ def public_to_der
225
+ to_der
226
+ end
227
+
228
+ def to_pem
229
+ return super unless JAVA_ED25519
230
+
231
+ "-----BEGIN PUBLIC KEY-----\n#{Internal::Util.base64_encode(to_der)}\n-----END PUBLIC KEY-----\n"
232
+ end
233
+
234
+ private
235
+
236
+ def java_verify(signature, data)
237
+ sig = java.security.Signature.getInstance("Ed25519")
238
+ sig.initVerify(@key)
239
+ sig.update(data.to_java_bytes)
240
+ sig.verify(signature.to_java_bytes)
241
+ rescue java.security.GeneralSecurityException => e
242
+ logger.debug { "Ed25519 verification failed: #{e}" }
243
+ false
244
+ end
180
245
  end
181
246
  end
182
247
  end
@@ -47,6 +47,13 @@ module Sigstore
47
47
  def base64_decode(string)
48
48
  string.unpack1("m0")
49
49
  end
50
+
51
+ # The DSSE Pre-Authentication Encoding over a payload and its type, as
52
+ # defined by the DSSE v1 spec. This is the byte string that is signed (and
53
+ # verified) for a DSSE envelope.
54
+ def dsse_pae(payload_type, payload)
55
+ "DSSEv1 #{payload_type.bytesize} #{payload_type} #{payload.bytesize} #{payload}".b
56
+ end
50
57
  end
51
58
  end
52
59
  end
@@ -111,7 +111,12 @@ module Sigstore
111
111
  end
112
112
 
113
113
  def tbs_certificate_der
114
- if openssl.respond_to?(:tbs_bytes)
114
+ # jruby-openssl < 0.19.0's X509::Certificate#tbs_bytes does not preserve
115
+ # extension order after reassigning extensions, yielding the wrong TBS and
116
+ # breaking SCT verification. Fall through to the manual ASN.1 path there,
117
+ # which manipulates the DER directly and is engine-independent.
118
+ if openssl.respond_to?(:tbs_bytes) &&
119
+ (RUBY_ENGINE != "jruby" || Gem::Version.new(JOpenSSL::VERSION) >= Gem::Version.new("0.19.0"))
115
120
  cert = openssl.dup
116
121
  short_name = Extension::PrecertificateSignedCertificateTimestamps.oid.short_name
117
122
  cert.extensions = cert.extensions.reject! do |ext|
@@ -92,6 +92,16 @@ module Sigstore
92
92
  raise Error::InvalidVerificationInput, "bundle with message_signature requires an artifact"
93
93
  end
94
94
 
95
+ @hashed_input = self.class.hashed_input_for(artifact)
96
+
97
+ freeze
98
+ end
99
+
100
+ # Derive the SHA2-256 HashOutput the verifier checks signatures and Rekor
101
+ # entries against, from any of the Artifact oneof variants: the raw bytes
102
+ # (:artifact), a "sha256:"-prefixed URI (:artifact_uri), or a typed digest
103
+ # (:artifact_digest, protobuf-specs v0.5.1+).
104
+ def self.hashed_input_for(artifact)
95
105
  case artifact.data
96
106
  when :artifact_uri
97
107
  unless artifact.artifact_uri.start_with?("sha256:")
@@ -99,26 +109,40 @@ module Sigstore
99
109
  "artifact_uri must be prefixed with 'sha256:'"
100
110
  end
101
111
 
102
- @hashed_input = Common::V1::HashOutput.new.tap do |hash_output|
112
+ Common::V1::HashOutput.new.tap do |hash_output|
103
113
  hash_output.algorithm = Common::V1::HashAlgorithm::SHA2_256
104
114
  hexdigest = artifact.artifact_uri.split(":", 2).last
105
115
  hash_output.digest = Internal::Util.hex_decode(hexdigest)
106
116
  end
107
117
  when :artifact
108
- @hashed_input = Common::V1::HashOutput.new.tap do |hash_output|
118
+ Common::V1::HashOutput.new.tap do |hash_output|
109
119
  hash_output.algorithm = Common::V1::HashAlgorithm::SHA2_256
110
120
  hash_output.digest = OpenSSL::Digest.new("SHA256").update(artifact.artifact).digest
111
121
  end
122
+ when :artifact_digest
123
+ # The rest of the pipeline (message-signature and Rekor digest checks)
124
+ # operates on SHA2-256, so reject other algorithms.
125
+ artifact.artifact_digest.tap do |hash_output|
126
+ unless hash_output.algorithm == Common::V1::HashAlgorithm::SHA2_256
127
+ raise Error::InvalidVerificationInput,
128
+ "unsupported artifact digest algorithm: #{hash_output.algorithm}"
129
+ end
130
+ end
112
131
  else
113
132
  raise Error::InvalidVerificationInput, "Unsupported artifact data: #{artifact.data}"
114
133
  end
115
-
116
- freeze
117
134
  end
118
135
  end
119
136
 
120
137
  class SBundle < DelegateClass(Bundle::V1::Bundle)
121
- attr_reader :bundle_type, :leaf_certificate
138
+ attr_reader :bundle_type, :leaf_certificate, :signing_key_hint
139
+
140
+ # A bundle whose signing identity is a bare public key (a "managed" /
141
+ # bring-your-own-key signature) rather than a Fulcio-issued certificate.
142
+ # The key itself is supplied out-of-band; the bundle only carries a hint.
143
+ def key_based?
144
+ !@signing_key_hint.nil?
145
+ end
122
146
 
123
147
  def initialize(*)
124
148
  super
@@ -138,11 +162,24 @@ module Sigstore
138
162
  new(bundle)
139
163
  end
140
164
 
141
- def expected_tlog_entry(hashed_input)
165
+ # +verifier_pem+ is the PEM of the public key the entry should be bound to. For
166
+ # certificate bundles it defaults to the leaf certificate's PEM; for managed-key
167
+ # bundles the caller passes the supplied key's SubjectPublicKeyInfo PEM.
168
+ def expected_tlog_entry(hashed_input, verifier_pem = leaf_certificate&.to_pem)
142
169
  case content
143
170
  when :message_signature
144
- expected_hashed_rekord_tlog_entry(hashed_input)
171
+ expected_hashed_rekord_tlog_entry(hashed_input, verifier_pem)
145
172
  when :dsse_envelope
173
+ # The DSSE-v1 (Rekor v1) expected-entry builders embed the signing leaf
174
+ # certificate. Key-based (managed-key) DSSE is only supported on Rekor v2, whose
175
+ # consistency check runs before this method; reaching here without a leaf
176
+ # certificate means a key-based bundle carries a v1 DSSE entry, which we cannot
177
+ # build an expected entry for. Fail cleanly rather than dereferencing a nil cert.
178
+ if leaf_certificate.nil?
179
+ raise Error::InvalidBundle,
180
+ "key-based DSSE bundles are only supported with Rekor v2 entries"
181
+ end
182
+
146
183
  rekor_entry = verification_material.tlog_entries.first
147
184
  canonicalized_body = begin
148
185
  JSON.parse(rekor_entry.canonicalized_body)
@@ -152,9 +189,9 @@ module Sigstore
152
189
 
153
190
  case kind_version = canonicalized_body.values_at("kind", "apiVersion")
154
191
  when %w[dsse 0.0.1]
155
- expected_dsse_0_0_1_tlog_entry
192
+ expected_dsse_0_0_1_tlog_entry(verifier_pem)
156
193
  when %w[intoto 0.0.2]
157
- expected_intoto_0_0_2_tlog_entry
194
+ expected_intoto_0_0_2_tlog_entry(verifier_pem)
158
195
  else
159
196
  raise Error::InvalidRekorEntry, "Unhandled rekor entry kind/version: #{kind_version.inspect}"
160
197
  end
@@ -193,7 +230,10 @@ module Sigstore
193
230
 
194
231
  case verification_material.content
195
232
  when :public_key
196
- raise Error::Unimplemented, "public_key content of bundle"
233
+ # Managed key: the verifying key is provided out-of-band; the bundle only
234
+ # carries a hint identifying it. There is no certificate to anchor.
235
+ @signing_key_hint = verification_material.public_key.hint
236
+ return
197
237
  when :x509_certificate_chain
198
238
  certs = verification_material.x509_certificate_chain.certificates.map do |cert|
199
239
  Internal::X509::Certificate.read(cert.raw_bytes)
@@ -211,13 +251,13 @@ module Sigstore
211
251
  raise Error::InvalidBundle, "expected certificate to be leaf" unless @leaf_certificate.leaf?
212
252
  end
213
253
 
214
- def expected_hashed_rekord_tlog_entry(hashed_input)
254
+ def expected_hashed_rekord_tlog_entry(hashed_input, verifier_pem)
215
255
  {
216
256
  "spec" => {
217
257
  "signature" => {
218
258
  "content" => Internal::Util.base64_encode(message_signature.signature),
219
259
  "publicKey" => {
220
- "content" => Internal::Util.base64_encode(leaf_certificate.to_pem)
260
+ "content" => Internal::Util.base64_encode(verifier_pem)
221
261
  }
222
262
  },
223
263
  "data" => {
@@ -232,7 +272,7 @@ module Sigstore
232
272
  }
233
273
  end
234
274
 
235
- def expected_intoto_0_0_2_tlog_entry
275
+ def expected_intoto_0_0_2_tlog_entry(_verifier_pem)
236
276
  {
237
277
  "apiVersion" => "0.0.2",
238
278
  "kind" => "intoto",
@@ -263,7 +303,7 @@ module Sigstore
263
303
  }
264
304
  end
265
305
 
266
- def expected_dsse_0_0_1_tlog_entry
306
+ def expected_dsse_0_0_1_tlog_entry(verifier_pem)
267
307
  {
268
308
  "apiVersion" => "0.0.1",
269
309
  "kind" => "dsse",
@@ -276,7 +316,7 @@ module Sigstore
276
316
  dsse_envelope.signatures.map do |sig|
277
317
  {
278
318
  "signature" => Internal::Util.base64_encode(sig.sig),
279
- "verifier" => Internal::Util.base64_encode(leaf_certificate.to_pem)
319
+ "verifier" => Internal::Util.base64_encode(verifier_pem)
280
320
  }
281
321
  end
282
322
  }
data/lib/sigstore/oidc.rb CHANGED
@@ -123,6 +123,9 @@ module Sigstore
123
123
  end
124
124
 
125
125
  def validate_nbf(nbf, now, leeway)
126
+ # The nbf ("not before") claim is optional (RFC 7519); skip validation when absent.
127
+ return if nbf.nil?
128
+
126
129
  raise Error::InvalidIdentityToken, "nbf claim must be an integer" unless nbf.is_a?(Integer)
127
130
  raise Error::InvalidIdentityToken, "nbf claim is in the future" if nbf > now + leeway
128
131
  end
@@ -87,6 +87,14 @@ module Sigstore
87
87
  end
88
88
  end
89
89
 
90
+ # No identity to check: used for managed-key (bring-your-own-key) verification,
91
+ # where trust comes from the supplied key rather than a certificate identity.
92
+ class UnsafeNoOp
93
+ def verify(_cert)
94
+ VerificationSuccess.new
95
+ end
96
+ end
97
+
90
98
  class Identity
91
99
  def initialize(identity:, issuer:)
92
100
  @identity = identity
@@ -65,15 +65,25 @@ module Sigstore
65
65
 
66
66
  def verify(rekor_keyring, key_id)
67
67
  data = note.encode("utf-8")
68
- signatures.each do |signature|
69
- sig_hash = key_id[0, 4]
70
- if signature.sig_hash != sig_hash
71
- raise Error::InvalidCheckpoint,
72
- "sig_hash hint #{signature.sig_hash.inspect} does not match key_id #{sig_hash.inspect}"
73
- end
74
-
68
+ key_hint = key_id[0, 4]
69
+
70
+ # A checkpoint may also carry cosignatures from witnesses whose verification
71
+ # keys we do not yet know; their key hints differ from the log's, and the
72
+ # signed-note / Rekor v2 spec says to ignore those lines. We require that at
73
+ # least one signature made with the log's own key verifies.
74
+ candidates = signatures.select { |signature| signature.sig_hash == key_hint }
75
+ raise Error::InvalidCheckpoint, "no checkpoint signature matching the log key" if candidates.empty?
76
+
77
+ # Keyring#verify raises KeyError when the log key id is not in the keyring (an
78
+ # untrusted or unknown log); treat that as a non-verifying candidate so it fails
79
+ # closed via the check below rather than escaping as an uncaught exception.
80
+ verified = candidates.any? do |signature|
75
81
  rekor_keyring.verify(key_id: key_id.unpack1("H*"), signature: signature.signature, data:)
82
+ rescue Error::InvalidSignature, KeyError
83
+ false
76
84
  end
85
+
86
+ raise Error::InvalidCheckpoint, "no valid checkpoint signature from the log key" unless verified
77
87
  end
78
88
  end
79
89
 
@@ -44,6 +44,39 @@ module Sigstore
44
44
  end
45
45
  end
46
46
 
47
+ # Client for the Rekor v2 (tiled / rekor-tiles) API. Unlike v1, creating an
48
+ # entry returns a fully-formed protobuf TransparencyLogEntry (including the
49
+ # inclusion proof and checkpoint) directly, so there is no separate retrieval
50
+ # step. See https://github.com/sigstore/rekor-tiles/blob/main/CLIENTS.md
51
+ class V2Client
52
+ def initialize(url:)
53
+ @url = URI.join("#{url.chomp("/")}/", "api/v2/")
54
+
55
+ net = defined?(Gem::Net) ? Gem::Net : Net
56
+ @session = net::HTTP.new(@url.host, @url.port)
57
+ @session.use_ssl = true
58
+ end
59
+
60
+ # Submit a proposed entry (a CreateEntryRequest, as a Hash) and return the
61
+ # integrated V1::TransparencyLogEntry. The log only responds once the entry
62
+ # has been included, so this call can take a while.
63
+ def create_entry(payload)
64
+ resp = @session.post2(URI.join(@url, "log/entries").path, payload.to_json,
65
+ { "Content-Type" => "application/json", "Accept" => "application/json",
66
+ "User-Agent" => Sigstore::USER_AGENT })
67
+
68
+ unless %w[200 201].include?(resp.code)
69
+ raise Error::FailedRekorPost,
70
+ "#{resp.code} #{resp.message.inspect}\n#{JSON.pretty_generate(payload)}\n#{resp.body}"
71
+ end
72
+ unless resp.content_type == "application/json"
73
+ raise Error::FailedRekorPost, "Unexpected content type: #{resp.content_type.inspect}"
74
+ end
75
+
76
+ V1::TransparencyLogEntry.decode_json(resp.body, registry: REGISTRY)
77
+ end
78
+ end
79
+
47
80
  class Log
48
81
  def initialize(url, session:)
49
82
  @url = url