sigstore 0.1.1

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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +7 -0
  3. data/CODEOWNERS +6 -0
  4. data/LICENSE +201 -0
  5. data/README.md +26 -0
  6. data/data/_store/prod/root.json +165 -0
  7. data/data/_store/prod/trusted_root.json +114 -0
  8. data/data/_store/staging/root.json +107 -0
  9. data/data/_store/staging/trusted_root.json +87 -0
  10. data/lib/sigstore/error.rb +43 -0
  11. data/lib/sigstore/internal/json.rb +53 -0
  12. data/lib/sigstore/internal/key.rb +183 -0
  13. data/lib/sigstore/internal/keyring.rb +42 -0
  14. data/lib/sigstore/internal/merkle.rb +117 -0
  15. data/lib/sigstore/internal/set.rb +42 -0
  16. data/lib/sigstore/internal/util.rb +52 -0
  17. data/lib/sigstore/internal/x509.rb +460 -0
  18. data/lib/sigstore/models.rb +272 -0
  19. data/lib/sigstore/oidc.rb +149 -0
  20. data/lib/sigstore/policy.rb +104 -0
  21. data/lib/sigstore/rekor/checkpoint.rb +114 -0
  22. data/lib/sigstore/rekor/client.rb +136 -0
  23. data/lib/sigstore/signer.rb +280 -0
  24. data/lib/sigstore/trusted_root.rb +116 -0
  25. data/lib/sigstore/tuf/config.rb +46 -0
  26. data/lib/sigstore/tuf/error.rb +49 -0
  27. data/lib/sigstore/tuf/file.rb +96 -0
  28. data/lib/sigstore/tuf/keys.rb +42 -0
  29. data/lib/sigstore/tuf/roles.rb +106 -0
  30. data/lib/sigstore/tuf/root.rb +53 -0
  31. data/lib/sigstore/tuf/snapshot.rb +45 -0
  32. data/lib/sigstore/tuf/targets.rb +84 -0
  33. data/lib/sigstore/tuf/timestamp.rb +39 -0
  34. data/lib/sigstore/tuf/trusted_metadata_set.rb +193 -0
  35. data/lib/sigstore/tuf/updater.rb +267 -0
  36. data/lib/sigstore/tuf.rb +158 -0
  37. data/lib/sigstore/verifier.rb +492 -0
  38. data/lib/sigstore/version.rb +19 -0
  39. data/lib/sigstore.rb +44 -0
  40. metadata +128 -0
@@ -0,0 +1,267 @@
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_relative "config"
18
+ require_relative "trusted_metadata_set"
19
+ require_relative "root"
20
+ require_relative "snapshot"
21
+ require_relative "targets"
22
+ require_relative "timestamp"
23
+
24
+ module Sigstore::TUF
25
+ class Updater
26
+ include Sigstore::Loggable
27
+
28
+ def initialize(metadata_dir:, metadata_base_url:, target_base_url:, target_dir:, fetcher:,
29
+ config: UpdaterConfig.new)
30
+ @dir = metadata_dir
31
+ @metadata_base_url = "#{metadata_base_url.to_s.chomp("/")}/"
32
+ @target_dir = target_dir
33
+ @target_base_url = target_base_url && "#{target_base_url.to_s.chomp("/")}/"
34
+
35
+ @fetcher = fetcher
36
+ @config = config
37
+
38
+ unless %i[metadata simple].include? @config.envelope_type
39
+ raise ArgumentError, "Unsupported envelope type: #{@config[:envelope_type].inspect}"
40
+ end
41
+
42
+ data = load_local_metadata("root")
43
+ @trusted_set = TrustedMetadataSet.new(data, "metadata", reference_time: Time.now)
44
+ end
45
+
46
+ def refresh
47
+ load_root
48
+ load_timestamp
49
+ load_snapshot
50
+ load_targets(Targets::TYPE, Root::TYPE)
51
+ end
52
+
53
+ def get_targetinfo(target_path)
54
+ refresh unless @trusted_set.include? Targets::TYPE
55
+ preorder_depth_first_walk(target_path)
56
+ end
57
+
58
+ def find_cached_target(target_info, filepath = nil)
59
+ filepath ||= generate_target_file_path(target_info)
60
+
61
+ begin
62
+ data = File.binread(filepath)
63
+ target_info.verify_length_and_hashes(data)
64
+ filepath
65
+ rescue Errno::ENOENT, Error::LengthOrHashMismatch => e
66
+ logger.debug { "No cached target at #{filepath}: #{e.class} #{e.message}" }
67
+ nil
68
+ end
69
+ end
70
+
71
+ def download_target(target_info, filepath = nil, target_base_url = nil)
72
+ target_base_url ||= @target_base_url
73
+ raise ArgumentError, "No target_base_url set" unless target_base_url
74
+
75
+ filepath ||= generate_target_file_path(target_info)
76
+
77
+ target_filepath = target_info.path
78
+ consistent_snapshot = @trusted_set.root.consistent_snapshot
79
+
80
+ if consistent_snapshot && @config.prefix_targets_with_hash
81
+ hashes = target_info.hashes.values
82
+ dir, sep, basename = target_filepath.rpartition("/")
83
+ target_filepath = "#{dir}#{sep}#{hashes.first}.#{basename}"
84
+ end
85
+
86
+ full_url = URI.join(target_base_url, target_filepath)
87
+ begin
88
+ resp_body = @fetcher.call(full_url)
89
+ target_info.verify_length_and_hashes(resp_body)
90
+
91
+ # TODO: atomic write
92
+ File.binwrite(filepath, resp_body)
93
+ rescue Error::Fetch => e
94
+ raise Error::Fetch,
95
+ "Failed to download target #{target_info.inspect} #{target_filepath.inspect} from #{e.response.uri}: " \
96
+ "#{e.message}"
97
+ end
98
+ logger.info { "Downloaded #{target_filepath} to #{filepath}" }
99
+ filepath
100
+ end
101
+
102
+ private
103
+
104
+ def load_local_metadata(role_name)
105
+ encoded_name = URI.encode_www_form_component(role_name)
106
+
107
+ File.binread(File.join(@dir, "#{encoded_name}.json"))
108
+ end
109
+
110
+ def load_root
111
+ lower_bound = @trusted_set.root.version + 1
112
+ upper_bound = lower_bound - 1 + @config.max_root_rotations
113
+
114
+ lower_bound.upto(upper_bound) do |version|
115
+ data = download_metadata(Root::TYPE, version)
116
+ rescue Error::UnsuccessfulResponse => e
117
+ logger.debug { "Failed to download root metadata v#{version}: #{e.class} #{e.message}" }
118
+ break if %w[403 404].include?(e.response.code)
119
+
120
+ raise
121
+ else
122
+ @trusted_set.root = data
123
+ persist_metadata(Root::TYPE, data)
124
+ end
125
+ end
126
+
127
+ def load_timestamp
128
+ begin
129
+ data = load_local_metadata(Timestamp::TYPE)
130
+ @trusted_set.timestamp = data
131
+ rescue Errno::ENOENT
132
+ logger.debug "No local timestamp found"
133
+ rescue Error::RepositoryError => e
134
+ logger.debug "Local timestamp not valid as final: #{e.class} #{e.message}"
135
+ end
136
+
137
+ data = download_metadata(Timestamp::TYPE, nil)
138
+
139
+ begin
140
+ @trusted_set.timestamp = data
141
+ rescue Error::EqualVersionNumber
142
+ logger.debug "Timestamp version did not increase"
143
+ return
144
+ end
145
+
146
+ persist_metadata(Timestamp::TYPE, data)
147
+ end
148
+
149
+ def load_snapshot
150
+ data = load_local_metadata(Snapshot::TYPE)
151
+ @trusted_set.send(:snapshot=, data, trusted: true)
152
+ logger.debug "Loaded snapshot from local metadata"
153
+ rescue Errno::ENOENT, Error::RepositoryError => e
154
+ logger.debug "Local snapshot not valid as final: #{e.class} #{e.message}"
155
+
156
+ snapshot_meta = @trusted_set.timestamp.snapshot_meta
157
+ version = snapshot_meta.version if @trusted_set.root.consistent_snapshot
158
+
159
+ data = download_metadata(Snapshot::TYPE, version)
160
+ @trusted_set.snapshot = data
161
+ persist_metadata(Snapshot::TYPE, data)
162
+ end
163
+
164
+ def load_targets(role, parent_role)
165
+ if @trusted_set.include?(role)
166
+ logger.debug { "Returning cached targets for #{role}" }
167
+ return @trusted_set[role]
168
+ end
169
+
170
+ begin
171
+ data = load_local_metadata(role)
172
+ @trusted_set.update_delegated_targets(data, role, parent_role).tap do
173
+ logger.debug { "Loaded targets for #{role} from local metadata" }
174
+ end
175
+ rescue Errno::ENOENT, Error::RepositoryError => e
176
+ logger.debug { "No local targets for #{role}, fetching: #{e.class} #{e.message}" }
177
+
178
+ snapshot = @trusted_set.snapshot
179
+ metainfo = snapshot.meta["#{role}.json"]
180
+ raise Error::RepositoryError, "role #{role} was delegated but is not part of snapshot" unless metainfo
181
+
182
+ version = metainfo.version if @trusted_set.root.consistent_snapshot
183
+ data = download_metadata(role, version)
184
+ delegated_targets = @trusted_set.update_delegated_targets(data, role, parent_role)
185
+ persist_metadata(role, data)
186
+ delegated_targets
187
+ end
188
+ end
189
+
190
+ def download_metadata(role_name, version)
191
+ url = metadata_url(role_name, version)
192
+
193
+ logger.debug { "Downloading metadata for #{role_name} from #{url}" }
194
+
195
+ @fetcher.call(url)
196
+ end
197
+
198
+ def metadata_url(role_name, version)
199
+ encoded_name = URI.encode_www_form_component(role_name)
200
+ if version.nil?
201
+ URI.join(@metadata_base_url, "#{encoded_name}.json")
202
+ else
203
+ URI.join(@metadata_base_url, "#{version}.#{encoded_name}.json")
204
+ end
205
+ end
206
+
207
+ def persist_metadata(role_name, data)
208
+ logger.debug { "Persisting metadata for #{role_name}" }
209
+
210
+ encoded_name = URI.encode_www_form_component(role_name)
211
+ filename = File.join(@dir, "#{encoded_name}.json")
212
+ Tempfile.create("", @dir) do |f|
213
+ f.binmode
214
+ f.write(data)
215
+ f.close
216
+
217
+ File.rename(f.path, filename)
218
+ end
219
+ end
220
+
221
+ def preorder_depth_first_walk(target_path)
222
+ logger.debug { "Searching for target #{target_path}" }
223
+
224
+ delegations_to_visit = [[Targets::TYPE, Root::TYPE]]
225
+ visited_role_names = Set.new
226
+
227
+ while delegations_to_visit.any? && visited_role_names.size < @config.max_delegations
228
+ role_name, parent_role = delegations_to_visit.pop
229
+ next if visited_role_names.include?(role_name)
230
+
231
+ targets = load_targets(role_name, parent_role)
232
+ target = targets.targets.fetch(target_path, nil)
233
+
234
+ return target if target
235
+
236
+ visited_role_names.add(role_name)
237
+
238
+ next unless targets.delegations.any?
239
+
240
+ child_roles_to_visit = []
241
+
242
+ targets.delegations.roles_for_target(target_path).each do |child_name, delegated_role|
243
+ child_roles_to_visit << [child_name, role_name]
244
+ next unless delegated_role.terminating?
245
+
246
+ logger.debug { "Terminating delegation found for #{child_name}" }
247
+ delegations_to_visit.clear
248
+ break
249
+ end
250
+
251
+ delegations_to_visit.concat child_roles_to_visit.reverse
252
+ end
253
+
254
+ logger.warn { "Max delegations reached, stopping search" } if delegations_to_visit.any?
255
+
256
+ nil
257
+ end
258
+
259
+ def generate_target_file_path(target_info)
260
+ raise ArgumentError, "target_dir not set" unless @target_dir
261
+ raise ArgumentError, "target_info required" unless target_info
262
+
263
+ filename = URI.encode_www_form_component(target_info.path)
264
+ File.join(@target_dir, filename)
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,158 @@
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_relative "tuf/updater"
18
+ require "tempfile"
19
+ require "uri"
20
+ require "net/http"
21
+ require "rubygems/remote_fetcher"
22
+
23
+ module Sigstore
24
+ module TUF
25
+ DEFAULT_TUF_URL = "https://tuf-repo-cdn.sigstore.dev"
26
+ STAGING_TUF_URL = "https://tuf-repo-cdn.sigstage.dev"
27
+
28
+ class TrustUpdater
29
+ include Loggable
30
+
31
+ Net = defined?(Gem::Net) ? Gem::Net : Net
32
+
33
+ attr_reader :updater
34
+
35
+ def initialize(metadata_url, offline, metadata_dir: nil, targets_dir: nil, target_base_url: nil,
36
+ config: UpdaterConfig.new)
37
+ @repo_url = metadata_url
38
+
39
+ default_metadata_dir, default_targets_dir = get_dirs(metadata_url) unless metadata_dir && targets_dir
40
+ @metadata_dir = metadata_dir || default_metadata_dir
41
+ @targets_dir = targets_dir || default_targets_dir
42
+
43
+ @offline = offline
44
+
45
+ rsrc_prefix = if @repo_url == DEFAULT_TUF_URL
46
+ "prod"
47
+ elsif @repo_url == STAGING_TUF_URL
48
+ "staging"
49
+ end
50
+
51
+ FileUtils.mkdir_p @metadata_dir
52
+ FileUtils.mkdir_p @targets_dir
53
+
54
+ if rsrc_prefix
55
+ tuf_root = File.join(@metadata_dir, "root.json")
56
+
57
+ unless File.exist?(tuf_root)
58
+ File.open(tuf_root, "wb") do |f|
59
+ File.open(File.expand_path("../../data/_store/#{rsrc_prefix}/root.json", __dir__), "rb") do |r|
60
+ logger.info { "Copying root.json from #{r.path} to #{f.path}" }
61
+ IO.copy_stream(r, f)
62
+ end
63
+ end
64
+ end
65
+
66
+ trusted_root_target = File.join(@targets_dir, "trusted_root.json")
67
+
68
+ unless File.exist?(trusted_root_target)
69
+ File.open(trusted_root_target, "wb") do |f|
70
+ File.open(File.expand_path("../../data/_store/#{rsrc_prefix}/trusted_root.json", __dir__),
71
+ "rb") do |r|
72
+ logger.info { "Copying trusted_root.json from #{r.path} to #{f.path}" }
73
+ IO.copy_stream(r, f)
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ return if @offline
80
+
81
+ @updater = Updater.new(
82
+ metadata_dir: @metadata_dir,
83
+ metadata_base_url: @repo_url,
84
+ target_base_url: (target_base_url && URI.parse(target_base_url)) ||
85
+ URI.join("#{@repo_url.to_s.chomp("/")}/", "targets/"),
86
+ target_dir: @targets_dir,
87
+ fetcher: method(:fetch),
88
+ config:
89
+ )
90
+ end
91
+
92
+ def get_dirs(url)
93
+ app_name = "sigstore-ruby"
94
+ app_author = "segiddins"
95
+
96
+ repo_base = URI.encode_uri_component(url)
97
+ home = Dir.home
98
+
99
+ data_home = ENV.fetch("XDG_DATA_HOME", File.join(home, ".local", "share"))
100
+ cache_home = ENV.fetch("XDG_CACHE_HOME", File.join(home, ".cache"))
101
+ tuf_data_dir = File.join(data_home, app_name, app_author, "tuf")
102
+ tuf_cache_dir = File.join(cache_home, app_name, app_author, "tuf")
103
+
104
+ [File.join(tuf_data_dir, repo_base), File.join(tuf_cache_dir, repo_base)]
105
+ end
106
+
107
+ def trusted_root_path
108
+ unless @updater
109
+ logger.info { "Offline mode: using cached trusted root" }
110
+ return File.join(@targets_dir, "trusted_root.json")
111
+ end
112
+
113
+ root_info = @updater.get_targetinfo("trusted_root.json")
114
+ raise Error::NoTrustedRoot, "Unsupported TUF configuration: no trusted_root.json" unless root_info
115
+
116
+ path = @updater.find_cached_target(root_info)
117
+ path ||= @updater.download_target(root_info)
118
+
119
+ path
120
+ end
121
+
122
+ def refresh
123
+ raise ArgumentError, "Offline mode: cannot refresh" if @offline || !@updater
124
+
125
+ @updater.refresh
126
+ end
127
+
128
+ private
129
+
130
+ def fetch(uri)
131
+ uri = Gem::Uri.new uri
132
+ raise ArgumentError, "uri scheme is invalid: #{uri.scheme.inspect}" unless %w[http https].include?(uri.scheme)
133
+
134
+ fetcher = Gem::RemoteFetcher.fetcher
135
+ begin
136
+ response = fetcher.request(uri, Net::HTTP::Get, nil) do
137
+ nil
138
+ end
139
+ response.uri = uri
140
+ case response
141
+ when Net::HTTPOK
142
+ nil
143
+ when Net::HTTPMovedPermanently, Net::HTTPFound, Net::HTTPSeeOther,
144
+ Net::HTTPTemporaryRedirect
145
+ raise Error::UnsuccessfulResponse.new("should redirects be supported?", response)
146
+ else
147
+ raise Error::UnsuccessfulResponse.new("FetchError: #{response.code}", response)
148
+ end
149
+ response.body
150
+ rescue (defined?(Gem::Timeout::Error) ? Gem::Timeout::Error : Timeout::Error),
151
+ IOError, SocketError, SystemCallError,
152
+ *(OpenSSL::SSL::SSLError if Gem::HAVE_OPENSSL) => e
153
+ raise Error::RemoteConnection, e.message
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end