www/secretary/workbench/views/actions/check-signature.json.rb (161 lines of code) (raw):

# frozen_string_literal: true require 'uri' require 'net/http' MAX_KEY_SIZE = 125000 # don't import if the ascii keyfile is larger than this # check signature on an attachment # if $0 == __FILE__ require 'wunderbar' $LOAD_PATH.unshift '/srv/whimsy/lib' require 'whimsy/asf' require_relative '../../models/mailbox' end ENV['GNUPGHOME'] = GNUPGHOME if GNUPGHOME # see WHIMSY-274 for secure servers # ** N.B. ensure the keyserver URI is known below ** KEYSERVERS = %w{keyserver.ubuntu.com} # openpgp does not return the uid needed by gpg # ** N.B. ensure the keyserver URI is known below ** def getServerURI(server, keyid) if server == 'keys.openpgp.org' if keyid.length == 40 uri = "https://#{server}/vks/v1/by-fingerprint/#{keyid}" else uri = "https://#{server}/vks/v1/by-keyid/#{keyid}" end elsif server == 'keyserver.ubuntu.com' uri = "https://#{server}/pks/lookup?search=0x#{keyid}&op=get" else # default to format used by sks-keyserver pool members uri = "https://#{server}/pks/lookup?search=0x#{keyid}&exact=on&options=mr&op=get" end Wunderbar.warn uri return uri end # fetch the Key from the URI and store in the file def getURI(uri, file) uri = URI.parse(uri) opts = {use_ssl: uri.scheme == 'https'} # The pool needs a special CA cert Net::HTTP.start(uri.host, uri.port, opts ) do |https| https.request_get(uri.request_uri) do |res| unless res.code == '200' raise Exception.new("Get #{uri} failed with #{res.code}: #{res.message}") end cl = res.content_length if cl Wunderbar.warn "Content-Length: #{cl}" if cl > MAX_KEY_SIZE # fail early raise Exception.new("Content-Length: #{cl} > #{MAX_KEY_SIZE}") end else Wunderbar.warn 'Content-Length not provided, continuing' end File.open(file, 'w') do |f| # Save the data directly; don't store in memory res.read_body do |segment| f.write segment end end size = File.size(file) Wunderbar.warn "File: #{file} Size: #{size}" if size > MAX_KEY_SIZE raise Exception.new("File: #{file} size #{size} > #{MAX_KEY_SIZE}") end end end end def validate_sig(attachment, signature, msgid, message) # pick the latest gpg version gpg = `which gpg2`.chomp gpg = `which gpg`.chomp if gpg.empty? # run gpg verify command - this is needed to determine the key-id # TODO: may need to drop the keyid-format parameter when gpg is updated as it might # reduce the keyid length from the full fingerprint out, err, rc = Open3.capture3 gpg, '--keyid-format', 'long', # Show a longer id '--verify', signature.path, attachment.path # N.B. the code now always fetches the key, so it is guaranteed current. # Might need to consider allowing for using a cached key if fetches fail frequently, # but this should probably be on demand only # Allow auto key fetch to be turned off; create the file /srv/gpg/whimsy_use_db # This is intended for local testing fetchKey = !File.exist?('/srv/gpg/whimsy_use_db') # If the key is not in the database, we need to try and fetch it unless fetchKey if err.include? "gpg: Can't check signature: No public key" or err.include? "gpg: Can't check signature: public key not found" then fetchKey = true end end # Look for the keyid so we can fetch the current key keyid = err[/[RD]SA key (ID )?(\w+)/,2] # we have a keyid and we need to fetch it if keyid and fetchKey then # Try to fetch the key Dir.mktmpdir do |dir| found = false tmpfile = File.join(dir, keyid) KEYSERVERS.each do |server| begin uri = getServerURI(server, keyid) # get the public key if possible (throws if not) getURI(uri, tmpfile) # import the key for use in validation out, err, rc = Open3.capture3 gpg, '--batch', '--import', tmpfile # For later analysis Wunderbar.warn "#{gpg} --import #{tmpfile} rc=#{rc} out=#{out} err=#{err}" if err.include?('processed: 1') # downloaded key is valid; store it for posterity Dir.mktmpdir do |tmpdir| container = ASF::SVN.svnpath!('iclas', '__keys__') ASF::SVN.svn!('checkout',[container, tmpdir], {depth: 'empty', env: env}) outfile = File.join(tmpdir, keyid) # Just in case we already have a copy ASF::SVN.svn!('update', outfile, {env: env}) present = File.exist? outfile FileUtils.cp(tmpfile, outfile) # add the latest copy if present # must have been dropped from the pubkey database (or was maybe backfilled) Wunderbar.warn "Already have a copy of #{keyid}" # Has it changed? Wunderbar.warn ASF::SVN.svn('diff', outfile, {verbose: true}).inspect else # we have a new key ASF::SVN.svn!('add', outfile, {verbose: true}) end begin message.add_email_details(outfile) rescue StandardError => err Wunderbar.warn "Failed to add properties for #{keyid} - #{err}" end ASF::SVN.svn!('commit', outfile, {msg: "Adding key for msgid: #{msgid}", env: env}) end else Wunderbar.warn "Failed to import #{keyid}" end found = true rescue Exception => e Wunderbar.warn "GET uri=#{uri} e=#{e}" err = "Key #{keyid} not found: #{e.to_s}".dup # Dup needed to unfreeze string for later end break if found end if found # run gpg verify command again # TODO: may need to drop the keyid-format parameter when gpg is updated as it might # reduce the keyid length from the full fingerprint out, err, rc = Open3.capture3 gpg, '--keyid-format', 'long', # Show a longer id '--verify', signature.path, attachment.path end end end # list of strings to ignore ignore = [ /^gpg:\s+WARNING: This key is not certified with a trusted signature!$/, /^gpg:\s+There is no indication that the signature belongs to the owner\.$/ ] unless err.valid_encoding? err = err.force_encoding('windows-1252').encode('utf-8') end ignore.each {|re| err.gsub! re, ''} return out, err, rc end def process message = Mailbox.find(@message) # e.g. /secretary/workbench/yyyymm/123456789a/ begin # fetch attachment and signature # e.g. icla.pdf and icla.pdf.asc attachment = message.find(URI::RFC2396_Parser.new.unescape(@attachment)).as_file # This is derived from a URI signature = message.find(@signature).as_file # This is derived from the YAML file msgid = message.headers.select{|k,v| k.downcase == 'message-id'}.values.first out, err, rc = validate_sig(attachment, signature, msgid, message) ensure attachment.unlink if attachment signature.unlink if signature end return {output: out, error: err, rc: rc.exitstatus} end # Allow direct testing if $0 == __FILE__ yyyymmid = ARGV.shift or fail 'Need yyyymm/msgid' att = ARGV.shift || 'icla.pdf' sig = ARGV.shift || att + '.asc' @message = "/secretary/workbench/#{yyyymmid}/" @attachment=att @signature=sig ret = process if ret[:rc] == 0 puts "Success: #{ret[:output]} #{ret[:error]}" else puts "Failure(#{ret[:rc]}): #{ret[:output]} #{ret[:error]}" end else process # must be the last executable statement end