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