sigstore 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
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