Merge pull request #938 from mdklapwijk-forks/support-acng-ssl
[puppet-modules/puppetlabs-apt.git] / lib / puppet / provider / apt_key / apt_key.rb
1 require 'open-uri'
2 require 'net/ftp'
3 require 'tempfile'
4
5 Puppet::Type.type(:apt_key).provide(:apt_key) do
6   desc 'apt-key provider for apt_key resource'
7
8   confine    osfamily: :debian
9   defaultfor osfamily: :debian
10   commands   apt_key: 'apt-key'
11   commands   gpg: '/usr/bin/gpg'
12
13   def self.instances
14     cli_args = ['adv', '--no-tty', '--list-keys', '--with-colons', '--fingerprint', '--fixed-list-mode']
15
16     key_output = apt_key(cli_args).encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '')
17
18     pub_line, sub_line, fpr_line = nil
19
20     key_array = key_output.split("\n").map do |line|
21       if line.start_with?('pub')
22         pub_line = line
23         # reset fpr_line, to skip any previous subkeys which were collected
24         fpr_line = nil
25         sub_line = nil
26       elsif line.start_with?('sub')
27         sub_line = line
28       elsif line.start_with?('fpr')
29         fpr_line = line
30       end
31
32       if sub_line && fpr_line
33         sub_line, fpr_line = nil
34         next
35       end
36
37       next unless pub_line && fpr_line
38
39       line_hash = key_line_hash(pub_line, fpr_line)
40
41       # reset everything
42       pub_line, fpr_line = nil
43
44       expired = false
45
46       if line_hash[:key_expiry]
47         expired = Time.now >= line_hash[:key_expiry]
48       end
49
50       new(
51         name: line_hash[:key_fingerprint],
52         id: line_hash[:key_long],
53         fingerprint: line_hash[:key_fingerprint],
54         short: line_hash[:key_short],
55         long: line_hash[:key_long],
56         ensure: :present,
57         expired: expired,
58         expiry: line_hash[:key_expiry].nil? ? nil : line_hash[:key_expiry].strftime('%Y-%m-%d'),
59         size: line_hash[:key_size],
60         type: line_hash[:key_type],
61         created: line_hash[:key_created].strftime('%Y-%m-%d'),
62       )
63     end
64     key_array.compact!
65   end
66
67   def self.prefetch(resources)
68     apt_keys = instances
69     resources.each_key do |name|
70       if name.length == 40
71         provider = apt_keys.find { |key| key.fingerprint == name }
72         resources[name].provider = provider if provider
73       elsif name.length == 16
74         provider = apt_keys.find { |key| key.long == name }
75         resources[name].provider = provider if provider
76       elsif name.length == 8
77         provider = apt_keys.find { |key| key.short == name }
78         resources[name].provider = provider if provider
79       end
80     end
81   end
82
83   def self.key_line_hash(pub_line, fpr_line)
84     pub_split = pub_line.split(':')
85     fpr_split = fpr_line.split(':')
86
87     fingerprint = fpr_split.last
88     return_hash = {
89       key_fingerprint: fingerprint,
90       key_long: fingerprint[-16..-1], # last 16 characters of fingerprint
91       key_short: fingerprint[-8..-1], # last 8 characters of fingerprint
92       key_size: pub_split[2],
93       key_type: nil,
94       key_created: Time.at(pub_split[5].to_i),
95       key_expiry: pub_split[6].empty? ? nil : Time.at(pub_split[6].to_i),
96     }
97
98     # set key type based on types defined in /usr/share/doc/gnupg/DETAILS.gz
99     case pub_split[3]
100     when '1'
101       return_hash[:key_type] = :rsa
102     when '17'
103       return_hash[:key_type] = :dsa
104     when '18'
105       return_hash[:key_type] = :ecc
106     when '19'
107       return_hash[:key_type] = :ecdsa
108     end
109
110     return_hash
111   end
112
113   def source_to_file(value)
114     parsed_value = URI.parse(value)
115     if parsed_value.scheme.nil?
116       raise(_('The file %{_value} does not exist') % { _value: value }) unless File.exist?(value)
117       # Because the tempfile method has to return a live object to prevent GC
118       # of the underlying file from occuring too early, we also have to return
119       # a file object here.  The caller can still call the #path method on the
120       # closed file handle to get the path.
121       f = File.open(value, 'r')
122       f.close
123       f
124     else
125       begin
126         # Only send basic auth if URL contains userinfo
127         # Some webservers (e.g. Amazon S3) return code 400 if empty basic auth is sent
128         if parsed_value.userinfo.nil?
129           key = if parsed_value.scheme == 'https' && resource[:weak_ssl] == true
130                   open(parsed_value, ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE).read
131                 else
132                   parsed_value.read
133                 end
134         else
135           user_pass = parsed_value.userinfo.split(':')
136           parsed_value.userinfo = ''
137           key = open(parsed_value, http_basic_authentication: user_pass).read
138         end
139       rescue OpenURI::HTTPError, Net::FTPPermError => e
140         raise(_('%{_e} for %{_resource}') % { _e: e.message, _resource: resource[:source] })
141       rescue SocketError
142         raise(_('could not resolve %{_resource}') % { _resource: resource[:source] })
143       else
144         tempfile(key)
145       end
146     end
147   end
148
149   # The tempfile method needs to return the tempfile object to the caller, so
150   # that it doesn't get deleted by the GC immediately after it returns.  We
151   # want the caller to control when it goes out of scope.
152   def tempfile(content)
153     file = Tempfile.new('apt_key')
154     file.write content
155     file.close
156     # confirm that the fingerprint from the file, matches the long key that is in the manifest
157     if name.size == 40
158       if File.executable? command(:gpg)
159         extracted_key = execute(["#{command(:gpg)} --no-tty --with-fingerprint --with-colons #{file.path} | awk -F: '/^fpr:/ { print $10 }'"], failonfail: false)
160         extracted_key = extracted_key.chomp
161
162         found_match = false
163         extracted_key.each_line do |line|
164           if line.chomp == name
165             found_match = true
166           end
167         end
168         unless found_match
169           raise(_('The id in your manifest %{_resource} and the fingerprint from content/source don\'t match. Check for an error in the id and content/source is legitimate.') % { _resource: resource[:name] }) # rubocop:disable Metrics/LineLength
170         end
171       else
172         warning('/usr/bin/gpg cannot be found for verification of the id.')
173       end
174     end
175     file
176   end
177
178   # Update a key if it is expired
179   def update_expired_key
180     # Return without doing anything if refresh or expired is false
181     return unless resource[:refresh] == true && resource[:expired] == true
182
183     # Execute command to update key
184     command = []
185
186     unless resource[:source].nil? && resource[:content].nil?
187       raise(_('an unexpected condition occurred while trying to add the key: %{_resource}') % { _resource: resource[:id] })
188     end
189
190     # Breaking up the command like this is needed because it blows up
191     # if --recv-keys isn't the last argument.
192     command.push('adv', '--no-tty', '--keyserver', resource[:server])
193     unless resource[:options].nil?
194       command.push('--keyserver-options', resource[:options])
195     end
196     command.push('--recv-keys', resource[:id])
197   end
198
199   def exists?
200     update_expired_key
201     # report expired keys as non-existing when refresh => true
202     @property_hash[:ensure] == :present && !(resource[:refresh] && @property_hash[:expired])
203   end
204
205   def create
206     command = []
207     if resource[:source].nil? && resource[:content].nil?
208       # Breaking up the command like this is needed because it blows up
209       # if --recv-keys isn't the last argument.
210       command.push('adv', '--no-tty', '--keyserver', resource[:server])
211       unless resource[:options].nil?
212         command.push('--keyserver-options', resource[:options])
213       end
214       command.push('--recv-keys', resource[:id])
215     elsif resource[:content]
216       key_file = tempfile(resource[:content])
217       command.push('add', key_file.path)
218     elsif resource[:source]
219       key_file = source_to_file(resource[:source])
220       command.push('add', key_file.path)
221     # In case we really screwed up, better safe than sorry.
222     else
223       raise(_('an unexpected condition occurred while trying to add the key: %{_resource}') % { _resource: resource[:id] })
224     end
225     apt_key(command)
226     @property_hash[:ensure] = :present
227   end
228
229   def destroy
230     loop do
231       apt_key('del', resource.provider.short)
232       r = execute(["#{command(:apt_key)} list | grep '/#{resource.provider.short}\s'"], failonfail: false)
233       break unless r.exitstatus.zero?
234     end
235     @property_hash.clear
236   end
237
238   def read_only(_value)
239     raise(_('This is a read-only property.'))
240   end
241
242   mk_resource_methods
243
244   # Alias the setters of read-only properties
245   # to the read_only function.
246   alias_method :created=, :read_only
247   alias_method :expired=, :read_only
248   alias_method :expiry=, :read_only
249   alias_method :size=, :read_only
250   alias_method :type=, :read_only
251 end