Verified Commit 6655ae21 authored by paz's avatar paz
Browse files

Enable refreshing keys from keyservers.

A script meant to be run from cron weekly.

This patch also changes gpgcli and gpgcli_expect a little.
parent bf77eb85
......@@ -10,6 +10,15 @@ plugins_dir: /etc/schleuder/plugins
# How verbose should Schleuder log to syslog? (list-specific messages are written to the list's log-file).
log_level: warn
# Which keyserver to refresh keys from (used by `schleuder refresh_keys`, meant
# to be run from cron weekly).
# If you have gnupg 2.1, we strongly suggest to use a hkps-keyserver:
#keyserver: hkps://hkps.pool.sks-keyservers.net
# If you have gnupg 2.1 and TOR running locally, use a onion-keyserver:
#keyserver: hkp://jirk5u4osbsr34t5.onion
# The default works for all supported versions of gnupg:
keyserver: pool.sks-keyservers.net
# For these options see documentation for ActionMailer::smtp_settings, e.g. <http://api.rubyonrails.org/classes/ActionMailer/Base.html>.
smtp_settings:
address: localhost
......
......@@ -63,6 +63,18 @@ module Schleuder
end
end
desc 'refresh_keys', "Refresh all keys of all list from the keyservers sequentially (one by one). (This is supposed to be run from cron weekly.)"
def refresh_keys
List.all.each do |list|
I18n.locale = list.language
output = list.refresh_keys
if output.present?
msg = "#{I18n.t('refresh_keys_intro', email: list.email)}\n\n#{output}"
list.logger.notify_admin(msg, nil, I18n.t('refresh_keys'))
end
end
end
desc 'install', "Set-up or update Schleuder environment (create folders, copy files, fill the database)."
def install
config_dir = Pathname.new(ENV['SCHLEUDER_CONFIG']).dirname
......@@ -159,7 +171,7 @@ module Schleuder
# Clear passphrase of imported list-key.
output = list.key.clearpassphrase(conf['gpg_password'])
if output
if output.present?
fatal "while clearing passphrase of list-key: #{output.inspect}"
end
......@@ -268,6 +280,7 @@ Please notify the users and admins of this list of these changes.
KEYWORDS[keyword.downcase]
end.compact
end
end
end
end
......@@ -9,6 +9,7 @@ module Schleuder
'listlogs_dir' => '/var/lib/schleuder/lists',
'plugins_dir' => '/etc/schleuder/plugins',
'log_level' => 'warn',
'keyserver' => 'hkp://pool.sks-keyservers.net',
'smtp_settings' => {
'address' => 'localhost',
'port' => 25,
......@@ -109,6 +110,10 @@ module Schleuder
settings
end
def self.keyserver
instance.config['keyserver']
end
private
def load_config(filename)
......
module GPGME
class Ctx
IMPORT_FLAGS = {
'new key' => 1,
'new_uids' => 2,
'new_signatures' => 4,
'new_subkeys' => 8
}
def keyimport(*args)
self.import_keys(*args)
result = self.import_result
......@@ -35,29 +42,78 @@ module GPGME
GPGME::Engine.info.find {|e| e.protocol == GPGME::PROTOCOL_OpenPGP }
end
def self.refresh_keys(keys)
output = []
base_args = "--quiet --no-auto-check-trustdb --keyserver #{Conf.keyserver} --refresh-keys"
keys.each do |key|
args = "#{base_args} #{key.fingerprint}"
err, gpgout, _ = gpgcli(args)
gpgout = filter_gpgcli_output(gpgout)
output << filter_gpgcli_output(err)
# Add any gpgkeys-message (gpg 2.0 writes those messages to stdout).
# Those could e.g. report a failure to connect to the keyserver.
output << gpgout.select { |line| line.match(/^gpgkeys: .*$/) }
import_stats = translate_import_data(gpgout)
if import_stats.present?
output << I18n.t("key_updated", { fingerprint: key.fingerprint,
states: import_stats.join(', ') })
end
sleep rand(1.0..5.0)
end
GPGME::Ctx.gpgcli("--check-trustdb")
output.flatten.uniq.join
end
def self.translate_import_data(gpgoutput)
result = []
import_ok = gpgoutput.grep(/IMPORT_OK/).first
return result if import_ok.blank?
import_status = import_ok.split(/\s/).slice(2).to_i
return result if import_status.zero?
# TODO: Raise alarm if new key is found?
IMPORT_FLAGS.each do |text, int|
if (import_status & int) > 0
result << I18n.t("import_states.#{text}")
end
end
result
end
# Unfortunately we can't distinguish between a failure to connect the
# keyserver, and a failure to find the key on the server. So we try to
# filter misleading errors to check if there are any to be reported.
def self.filter_gpgcli_output(strings)
strings.reject do |line|
line.chomp == 'gpg: keyserver refresh failed: No data' ||
line.match(/^gpgkeys: key .* not found on keyserver/) ||
line.match(/^gpg: refreshing /) ||
line.match(/^gpg: requesting key /) ||
line.match(/^gpg: no valid OpenPGP data found/)
end
end
def self.gpgcli(args)
exitcode = -1
errors = ''
output = ''
errors = []
output = []
base_cmd = gpg_engine.file_name
base_args = "--armor --trust-model always --quiet --no-tty --command-fd 0 --status-fd 1"
base_args = "--no-greeting --no-permission-warning --quiet --armor --trust-model always --no-tty --command-fd 0 --status-fd 1"
cmd = [base_cmd, base_args, args].flatten.join(' ')
Open3.popen3(cmd) do |stdin, stdout, stderr, thread|
if block_given?
output = yield(stdin, stdout, stderr)
else
output = stdout.readlines
end
stdin.close
errors = stderr.readlines
exitcode = thread.value.exitstatus
end
if output.present?
output
elsif exitcode > 0
errors.join("\n")
else
nil
end
[errors, output, exitcode]
rescue Errno::ENOENT
raise 'Need gpg in $PATH or in $GPGBIN'
end
......@@ -78,7 +134,22 @@ module GPGME
end
end
end
nil
end
def self.spawn_daemon(name, args)
delete_daemon_socket(name)
log = "/tmp/schleuder-#{name}-#{rand}.log"
cmd = "#{name} #{args} --daemon > #{log} 2>&1"
if ! system(cmd)
return [false, "#{name} exited with code #{$?}, output in #{log}"]
end
end
def self.delete_daemon_socket(name)
path = File.join(ENV["GNUPGHOME"], "S.#{name}")
if File.exist?(path)
File.delete(path)
end
end
end
end
......@@ -49,12 +49,14 @@ module GPGME
end
# Specifying the key via fingerprint apparently doesn't work.
GPGME::Ctx.gpgcli("--quick-adduid #{uid} '#{uid} <#{email}>'")
errors, _ = GPGME::Ctx.gpgcli("--quick-adduid #{uid} '#{uid} <#{email}>'")
errors.join
end
# This method can be deleted once we cease to support gnupg 2.0.
def adduid_expect(uid, email)
GPGME::Ctx.gpgcli_expect("--allow-freeform-uid --edit-key '#{self.fingerprint}' adduid") do |line|
args = "--allow-freeform-uid --edit-key '#{self.fingerprint}' adduid"
errors, _ = GPGME::Ctx.gpgcli_expect(args) do |line|
case line.chomp
when /keygen.name/
uid
......@@ -70,6 +72,7 @@ module GPGME
[false, "Unexpected line: #{line}"]
end
end
errors.join
end
def clearpassphrase(oldpw)
......@@ -80,7 +83,8 @@ module GPGME
oldpw_given = false
# Don't use '--passwd', it claims to fail (even though it factually doesn't).
GPGME::Ctx.gpgcli_expect(" --pinentry-mode loopback --edit-key '#{self.fingerprint}' passwd") do |line|
args = "--pinentry-mode loopback --edit-key '#{self.fingerprint}' passwd"
errors, _, exitcode = GPGME::Ctx.gpgcli_expect(args) do |line|
case line
when /passphrase.enter/
if ! oldpw_given
......@@ -101,6 +105,15 @@ module GPGME
[false, "Unexpected line: #{line}"]
end
end
# Only show errors if something apparently went wrong. Otherwise we might
# leak useless strings from gpg and make the caller report errors even
# though this method succeeded.
if exitcode > 0
errors.join
else
nil
end
end
# This method can be deleted once we cease to support gnupg 2.0.
......@@ -114,7 +127,7 @@ module GPGME
return [false, "gpg-agent exited with code #{$?}, output in #{gpg_agent_log}"]
end
# Don't use '--passwd', it claims to fail (even though it factually doesn't).
output = GPGME::Ctx.gpgcli_expect("--edit-key '#{self.fingerprint}' passwd") do |line|
errors, _, exitcode = GPGME::Ctx.gpgcli_expect("--edit-key '#{self.fingerprint}' passwd") do |line|
case line
when /BAD_PASSPHRASE/
[false, 'bad passphrase']
......@@ -131,7 +144,15 @@ module GPGME
# gpg-agent terminates itself if its socket goes away.
delete_gpg_agent_socket
delete_file(gpg_agent_log)
output
# Only show errors if something apparently went wrong. Otherwise we might
# leak useless strings from gpg and make the caller report errors even
# though this method succeeded.
if exitcode > 0
errors.join
else
nil
end
end
# This method can be deleted once we cease to support gnupg 2.0.
......
......@@ -164,6 +164,10 @@ module Schleuder
text
end
def refresh_keys
GPGME::Ctx.refresh_keys(self.keys)
end
def self.by_recipient(recipient)
listname = recipient.gsub(/-(sendkey|request|owner|bounce)@/, '@')
where(email: listname).first
......
......@@ -119,3 +119,12 @@ de:
key_expires: "Läuft in %{days} Tagen ab:\n0x%{fingerprint} %{email}"
key_unusable: "Ist %{usability_issue}:\n0x%{fingerprint} %{email}"
missed_message_due_to_unusable_key: "Du hast eine Email von %{list_email} verpasst weil mit deinem Abo kein (benutzbarer) OpenPGP-Schlüssel verknüpft ist. Bitte kümmere dich darum."
refresh_keys: Schlüsselaktualisierung
refresh_keys_intro: "Die Aktualisierung aller Schlüssel des Schlüsselrings für Liste %{email} ergab dies:"
key_updated: Schlüssel %{fingerprint} wurde aktualisiert (%{states}).
import_states:
new_keys: neue Schlüssel
new_uids: neue User-IDs
new_subkeys: neue Unterschlüssel
new_signatures: neue Signaturen
......@@ -119,3 +119,12 @@ en:
key_expires: "Expires in %{days} days:\n0x%{fingerprint} %{email}"
key_unusable: "Is %{usability_issue}:\n0x%{fingerprint} %{email}"
missed_message_due_to_unusable_key: "You missed an email from %{list_email} because your subscription isn't associated with a (usable) OpenPGP key. Please fix this."
refresh_keys: Keys update
refresh_keys_intro: "Refreshing all keys from the keyring of list %{email} resulted in this:"
key_updated: Key %{fingerprint} was updated (%{states}).
import_states:
new_keys: new keys
new_uids: new user-IDs
new_subkeys: new subkeys
new_signatures: new signatures
-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: SKS 1.1.6+
Comment: Hostname: sks.spodhuis.org
mQENBExlilkBCACb2AQyclf7latAIE1kCTfKQ9jmcKyf959ymyhzoeNmBDpKjILC7MOXtICo
/V/xAzhWBK/vT9+56brGUBTugnW3yK+zllQprI3kIYaRS1SrbmKVwVse9qLVUL1BssohFaEe
QqT4MNh62ziJymqCguGEGXpYlEqzEDTmmhTANiPKRBZDrdfq3FU3OJUMTGzuG34mKmXMRr0a
zprF228LUujMMKyWhG1hxh3El04C4jPuMSbaVcwNE2rgIg8jaNAQuSyXkaprPZ8/nRG8UFGR
tCMEIEh6Ou6KybF1NI9LQKCwcsGcLHKU2u/8vOCExxdwl9Jjlqmof4FQV7bT++6SC0n3ABEB
AAG0EWV4cGlyZWQgPGJsYUBmb28+iQFVBBMBAgA/AhsDBgsJCAcDAgYVCAIJCgsEFgIDAQIe
AQIXgBYhBJh2nooQkfNr2IQD7PcaP4QS2DiJBQJYgOG1BQkMHGNXAAoJEPcaP4QS2DiJxiUH
/2xYCT+mldoaWMyJlBleSjx0wtWeGNayMuv0RjU2pIkBmslYp/ZZkt+JC3thpJneBW5pJuRB
qeUi7yTigdtOrH47g+FfIDCY89ymTDbYW4vpNnnsV7s+ke8tbEmTtMpjFypoTvbnGYlq8VLz
87eRcsLwADOAJfFBdnDD0tyNCrUY/V+Ti/ZI4bHoFA14t8Hm7MIDkfB6sVfzpnZd1ACj+klv
1rfq+9m56lsavS/dM+BlhwfRORT9cenuBs++AXXWvh1CZW/J06kFECG+ptqU5246nQcjE5GX
W8sC+TSq7OXSTQAJDF+aWqjA/JrbpSf/3r2/IU+mGH2Bwi7B5uBN6lG5AQ0ETGWKWQEIAMMZ
t/RMSzkcOltQLvy0l60ZaKmZBFOryBL10OgqMbma+WkUBE9MAm97CAjsMgJcy1Vjw/x4Vuxk
MYwRN266dp260O/I/0n/C6SdgcmQTmMl5mMQrKh+tYzUEfEdvZuQ1TSGvwRU28SFWMBvrZwk
+Pl6aL/dSHKcugmus74BlKv0GrZBbfye1JTFNcztVsZRHUxZlKgn6ASD8h5HYcnOILJvWaGW
+j2lmolP3n1s1VD/FTg0e1hxrlxXdfzwCV3gPDPYcz48gFuyLPcLd8gw2tHFG8wJTg+CyNJk
WhHFx0NFPk6MHE2BDhSZZAfSxMXD1PHwHY63C6C4bBpchXcWlEMAEQEAAYkBJQQYAQIADwUC
TGWKWQIbDAUJAAFRgAAKCRD3Gj+EEtg4iaqeB/9BC3RLq2Un04ySeYCvak2D4NCQKpM1pKT+
JCPiV4YbCSlFWgflUVNnSeSZmNwNNOsKOPmsQNpGjxDfwqwHWmjw/bkAGgrRvbysfDwjcvYH
KXX4gHv500pXIUlV8cBg5Cxsj+n/Yp21lkhunGdFUdj5WW7an9/Hcmo+aoKJpUxxQXDnlCNI
poj6iZ+c3RYbKsrVG5v3bktyHS9mTFXCZOx7uq5+DSLBNEQzL1Vl8xq8yGQC0zYCyIQbyPeb
BT0lB8GReq1bEbZW+2wEnSstp+HmxHc2CO1Ha9tJ3MlEJXbE7jPoOuHc+6DdF4eZwtQVMM8T
6+Z4f7GkRnaeG81EUOGZ
=4p8E
-----END PGP PUBLIC KEY BLOCK-----
......@@ -6,3 +6,4 @@ lists_dir: /tmp/schleuder-test/
listlogs_dir: /tmp/schleuder-test/
smtp_settings:
port: 2523
keyserver: hkp://127.0.0.1:11371
......@@ -87,4 +87,43 @@ describe 'cli' do
expect(subscription_emails).to eq ['schleuder2@example.org']
end
end
context '#refresh_keys' do
it 'updates one key from the keyserver' do
list = create(:list)
list.subscribe("admin@example.org", nil, true)
list.import_key(File.read("spec/fixtures/expired_key.txt"))
with_sks_mock do
Cli.new.refresh_keys
end
mail = Mail::TestMailer.deliveries.first
expect(Mail::TestMailer.deliveries.length).to eq 1
expect(mail.to_s).to include("Refreshing all keys from the keyring of list #{list.email} resulted in this")
expect(mail.to_s).to include("98769E8A1091F36BD88403ECF71A3F8412D83889 was updated (new signatures)")
teardown_list_and_mailer(list)
end
it 'reports errors from refreshing keys' do
list = create(:list)
list.subscribe("admin@example.org", nil, true)
list.import_key(File.read("spec/fixtures/expired_key.txt"))
Cli.new.refresh_keys
mail = Mail::TestMailer.deliveries.first
expect(Mail::TestMailer.deliveries.length).to eq 1
expect(mail.to_s).to include("Refreshing all keys from the keyring of list #{list.email} resulted in this")
if GPGME::Ctx.sufficient_gpg_version?('2.1')
expect(mail.to_s).to include("keyserver refresh failed: No keyserver available")
else
# The wording differs slightly among versions.
expect(mail.to_s).to match(/gpgkeys: .* error .* connect/)
end
teardown_list_and_mailer(list)
end
end
end
#!/usr/bin/env ruby
require 'sinatra/base'
class SksMock < Sinatra::Base
set :environment, :production
set :port, 11371
set :bind, '127.0.0.1'
set :logging, true
get '/status' do
'ok'
end
get '/pks/lookup' do
case params['search']
when '0x98769E8A1091F36BD88403ECF71A3F8412D83889'
File.read('spec/fixtures/expired_key_extended.txt')
else
404
end
end
# Run this class as application
run!
end
......@@ -4,6 +4,7 @@ ENV["SCHLEUDER_LIST_DEFAULTS"] = "etc/list-defaults.yml"
require 'bundler/setup'
Bundler.setup
require 'schleuder'
require 'schleuder/cli'
require 'database_cleaner'
require 'factory_girl'
......@@ -54,6 +55,14 @@ RSpec.configure do |config|
File.join(Conf.lists_dir, 'smtp-daemon-output')
end
def with_sks_mock
pid = Process.spawn('spec/sks-mock.rb', [:out, :err] => ["/tmp/sks-mock.log", 'w'])
sleep 1
yield
Process.kill 'TERM', pid
Process.wait pid
end
def start_smtp_daemon
if ! File.directory?(smtp_daemon_outputdir)
FileUtils.mkdir_p(smtp_daemon_outputdir)
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment