From 5cb695bcef94300136df608c0e2f20bf880dca05 Mon Sep 17 00:00:00 2001 From: David Cook Date: Thu, 30 Apr 2020 12:20:55 +0000 Subject: [PATCH] Bug 24632: Verify Koha plugin signatures against trusted author public keys The concept is that Koha plugin authors will sign their plugins using their RSA private key, and they'll share their public key with Koha users/the public. Koha users will upload/import these public keys into Koha. When uploading plugins, Koha users will also be prompted to upload a signature file, which will be provided by Koha plugin authors. By default, signature verification will be optional. However, if you enable the system preference "RequirePluginSignatures", you will only be able to upload Koha plugins which have been signed by a trusted author (ie an author for whom you have stored a public key). This patch adds the plugin_keys table and a web UI to store trusted author public keys, adds the "RequirePluginSignatures" system preference to secure the upload process, and updates the plugin upload to verify signatures. Test Plan: 0) Apply patch 1) Download Example Koha Plugin Developer public key https://bugs.koha-community.org/bugzilla3/attachment.cgi?id=104031 2) Download Kitchen Sink plugin signature https://bugs.koha-community.org/bugzilla3/attachment.cgi?id=104030 3) Download version 2.1.39 of the Kitchen Sink plugin https://github.com/bywatersolutions/koha-plugin-kitchen-sink/releases/download/v2.1.39/koha-plugin-kitchen-sink-v2.1.39.kpz 4) Go to /cgi-bin/koha/plugins/plugins-keys.pl 5) Upload "Example Koha Plugin Developer public key" here with any "Name" you want 6) Go to /cgi-bin/koha/plugins/plugins-upload.pl 7) Upload Kitchen Sink plugin 8) Notice it uploaded without a signature. 9) Uninstall Kitchen Sink plugin 10) Go to systme preferences and enable "RequirePluginSignatures" 11) Go to /cgi-bin/koha/plugins/plugins-upload.pl 12) Upload Kitchen Sink plugin 13) Notice an error message saying upload failed 14) Go to /cgi-bin/koha/plugins/plugins-home.pl 15) Observe there is no uploaded plugin 16) Go to /cgi-bin/koha/plugins/plugins-upload.pl 17) Upload Kitchen Sink plugin 18) Upload Kitchen Sink plugin signature 19) Notice the plugin uploaded Advanced Test Plan: Instead of downloading the Koha Sink plugin signature and public key, try creating your own keys and signature. The below is just a quick example, and not necessarily the recommended process. Consider a larger key size. 1) openssl genrsa -des3 -out private.pem 2048 2) openssl rsa -in private.pem -outform PEM -pubout -out public.pem 3) openssl dgst -sha256 -sign private.pem -out koha-plugin-kitchen-sink-v2.1.39.sig koha-plugin-kitchen-sink-v2.1.39.kpz 4) Repeat normal test plan from step 4 --- Koha/Plugins/Key.pm | 44 ++++++++++ Koha/Plugins/Keys.pm | 86 ++++++++++++++++++++ Koha/Schema/Result/PluginKey.pm | 93 ++++++++++++++++++++++ .../atomicupdate/bug24632-plugin-key-table.perl | 15 ++++ .../atomicupdate/bug24632-plugin-signature.sql | 2 + installer/data/mysql/kohastructure.sql | 9 +++ installer/data/mysql/sysprefs.sql | 1 + .../admin/preferences/enhanced_content.pref | 7 ++ .../prog/en/modules/plugins/plugins-home.tt | 1 + .../prog/en/modules/plugins/plugins-keys.tt | 92 +++++++++++++++++++++ .../prog/en/modules/plugins/plugins-upload.tt | 6 ++ plugins/plugins-keys.pl | 87 ++++++++++++++++++++ plugins/plugins-upload.pl | 22 +++++ 13 files changed, 465 insertions(+) create mode 100644 Koha/Plugins/Key.pm create mode 100644 Koha/Plugins/Keys.pm create mode 100644 Koha/Schema/Result/PluginKey.pm create mode 100644 installer/data/mysql/atomicupdate/bug24632-plugin-key-table.perl create mode 100644 installer/data/mysql/atomicupdate/bug24632-plugin-signature.sql create mode 100644 koha-tmpl/intranet-tmpl/prog/en/modules/plugins/plugins-keys.tt create mode 100644 plugins/plugins-keys.pl diff --git a/Koha/Plugins/Key.pm b/Koha/Plugins/Key.pm new file mode 100644 index 0000000000..ed42090846 --- /dev/null +++ b/Koha/Plugins/Key.pm @@ -0,0 +1,44 @@ +package Koha::Plugins::Key; + +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Carp; + +use Koha::Database; + +use base qw(Koha::Object); + +=head1 NAME + +Koha::Plugin::Method - Koha Plugin Method Object class + +=head1 API + +=head2 Class Methods + +=cut + +=head3 type + +=cut + +sub _type { + return 'PluginKey'; +} + +1; diff --git a/Koha/Plugins/Keys.pm b/Koha/Plugins/Keys.pm new file mode 100644 index 0000000000..cd1900efe7 --- /dev/null +++ b/Koha/Plugins/Keys.pm @@ -0,0 +1,86 @@ +package Koha::Plugins::Keys; + +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Carp; + +use Koha::Database; + +use Koha::Plugins::Key; + +use Crypt::OpenSSL::RSA; + +use base qw(Koha::Objects); + +=head1 NAME + +Koha::Plugin::Methods - Koha City Object set class + +=head1 API + +=head2 Class Methods + +=cut + +=head3 type + +=cut + +sub _type { + return 'PluginKey'; +} + +=head3 object_class + +=cut + +sub object_class { + return 'Koha::Plugins::Key'; +} + +sub verify_plugin { + my ($class,$args) = @_; + my $plugin_file = $args->{plugin_file}; + my $signature = $args->{signature}; + #FIXME: add data validation and error handling + my $verified; + if ($plugin_file && $signature){ + my @plugin_keys = $class->as_list(); + eval { + foreach my $plugin_key (@plugin_keys){ + my $key_string = $plugin_key->public_key; + if ($key_string){ + my $rsa_pub = Crypt::OpenSSL::RSA->new_public_key($key_string); + if ($rsa_pub){ + $rsa_pub->use_sha256_hash; + if ( $rsa_pub->verify($plugin_file, $signature) ){ + $verified = 1; + last; + } + } + } + } + }; + if ($@){ + warn $@; + } + } + return $verified; +} + +1; diff --git a/Koha/Schema/Result/PluginKey.pm b/Koha/Schema/Result/PluginKey.pm new file mode 100644 index 0000000000..9574029918 --- /dev/null +++ b/Koha/Schema/Result/PluginKey.pm @@ -0,0 +1,93 @@ +use utf8; +package Koha::Schema::Result::PluginKey; + +# Created by DBIx::Class::Schema::Loader +# DO NOT MODIFY THE FIRST PART OF THIS FILE + +=head1 NAME + +Koha::Schema::Result::PluginKey + +=cut + +use strict; +use warnings; + +use base 'DBIx::Class::Core'; + +=head1 TABLE: C + +=cut + +__PACKAGE__->table("plugin_keys"); + +=head1 ACCESSORS + +=head2 keyid + + data_type: 'integer' + is_auto_increment: 1 + is_nullable: 0 + +=head2 name + + data_type: 'varchar' + is_nullable: 0 + size: 100 + +=head2 public_key + + data_type: 'text' + is_nullable: 0 + +=cut + +__PACKAGE__->add_columns( + "keyid", + { data_type => "integer", is_auto_increment => 1, is_nullable => 0 }, + "name", + { data_type => "varchar", is_nullable => 0, size => 100 }, + "public_key", + { data_type => "text", is_nullable => 0 }, +); + +=head1 PRIMARY KEY + +=over 4 + +=item * L + +=back + +=cut + +__PACKAGE__->set_primary_key("keyid"); + +=head1 UNIQUE CONSTRAINTS + +=head2 C + +=over 4 + +=item * L + +=back + +=cut + +__PACKAGE__->add_unique_constraint("name", ["name"]); + + +# Created by DBIx::Class::Schema::Loader v0.07046 @ 2020-04-30 09:41:07 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:0FWQcA2u8gTSpoiGU7PVHw + + +sub koha_objects_class { + 'Koha::Plugins::Keys'; +} + +sub koha_object_class { + 'Koha::Plugins::Key'; +} + +1; diff --git a/installer/data/mysql/atomicupdate/bug24632-plugin-key-table.perl b/installer/data/mysql/atomicupdate/bug24632-plugin-key-table.perl new file mode 100644 index 0000000000..c0ce6ef9b3 --- /dev/null +++ b/installer/data/mysql/atomicupdate/bug24632-plugin-key-table.perl @@ -0,0 +1,15 @@ +$DBversion = 'XXX'; # will be replaced by the RM +if( CheckVersion( $DBversion ) ) { + $dbh->do(" + CREATE TABLE IF NOT EXISTS `plugin_keys` ( + `keyid` int(11) NOT NULL auto_increment, -- unique identifier assigned by Koha + `name` varchar(100) NOT NULL, -- short descriptive name to label public key + `public_key` TEXT NOT NULL, -- public key in PEM format + PRIMARY KEY (`keyid`), + CONSTRAINT UNIQUE `name` ( name ) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + "); + + # Always end with this (adjust the bug info) + NewVersion( $DBversion, 24632, "Add table to store plugin provider public keys"); +} diff --git a/installer/data/mysql/atomicupdate/bug24632-plugin-signature.sql b/installer/data/mysql/atomicupdate/bug24632-plugin-signature.sql new file mode 100644 index 0000000000..e28edcdb03 --- /dev/null +++ b/installer/data/mysql/atomicupdate/bug24632-plugin-signature.sql @@ -0,0 +1,2 @@ +INSERT IGNORE INTO systempreferences ( `variable`, `value`, `options`, `explanation`, `type` ) VALUES +('RequirePluginSignatures','0','','Require Koha plugins to be signed by a trusted author or prevent Koha plugin upload','YesNo'); diff --git a/installer/data/mysql/kohastructure.sql b/installer/data/mysql/kohastructure.sql index 3f8d490fe6..d600dca0c8 100644 --- a/installer/data/mysql/kohastructure.sql +++ b/installer/data/mysql/kohastructure.sql @@ -4475,6 +4475,15 @@ CREATE TABLE `problem_reports` ( CONSTRAINT `problem_reports_ibfk2` FOREIGN KEY (`branchcode`) REFERENCES `branches` (`branchcode`) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +DROP TABLE IF EXISTS `plugin_keys`; +CREATE TABLE `plugin_keys` ( + `keyid` int(11) NOT NULL auto_increment, -- unique identifier assigned by Koha + `name` varchar(100) NOT NULL, -- short descriptive name to label public key + `public_key` TEXT NOT NULL, -- public key in PEM format + PRIMARY KEY (`keyid`), + CONSTRAINT UNIQUE `name` ( name ) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + /*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; /*!40101 SET SQL_MODE=@OLD_SQL_MODE */; /*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; diff --git a/installer/data/mysql/sysprefs.sql b/installer/data/mysql/sysprefs.sql index ead29651f5..f94cd4980f 100644 --- a/installer/data/mysql/sysprefs.sql +++ b/installer/data/mysql/sysprefs.sql @@ -536,6 +536,7 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` ('ReplyToDefault','',NULL,'Use this email address as the replyto in emails','Free'), ('ReportsLog','0',NULL,'If ON, log information about reports.','YesNo'), ('RequestOnOpac','1',NULL,'If ON, globally enables patron holds on OPAC','YesNo'), +('RequirePluginSignatures','0','','Require Koha plugins to be signed by a trusted author or prevent Koha plugin upload','YesNo'), ('RequireStrongPassword','1','','Require a strong login password for staff and patrons','YesNo'), ('ReservesControlBranch','PatronLibrary','ItemHomeLibrary|PatronLibrary','Branch checked for members reservations rights','Choice'), ('ReservesMaxPickUpDelay','7','','Define the Maximum delay to pick up an item on hold','Integer'), diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/enhanced_content.pref b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/enhanced_content.pref index 1b5c0562bd..44019b72aa 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/enhanced_content.pref +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/enhanced_content.pref @@ -388,6 +388,13 @@ Enhanced Content: - for user access to OverDrive.
- If you enable access you must have a SIP connection registered with - OverDrive for patron authentication against Koha + Plugins: + - + - pref: RequirePluginSignatures + choices: + yes: Require + no: "Don't require" + - "Koha plugins to be signed by a trusted author or prevent Koha plugin upload" RecordedBooks: - - Include RecordedBooks availability information with the client secret diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/plugins/plugins-home.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/plugins/plugins-home.tt index 1b24428fde..6f681bb7aa 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/plugins/plugins-home.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/plugins/plugins-home.tt @@ -53,6 +53,7 @@ + Manage keys [% END %]

Plugins

diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/plugins/plugins-keys.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/plugins/plugins-keys.tt new file mode 100644 index 0000000000..c5d00894e3 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/plugins/plugins-keys.tt @@ -0,0 +1,92 @@ +[% USE raw %] +[% USE Asset %] +[% USE KohaDates %] +[% SET footerjs = 1 %] +[% INCLUDE 'doc-head-open.inc' %] +Koha › Tools › Plugins › Keys +[% INCLUDE 'doc-head-close.inc' %] + + + +[% INCLUDE 'header.inc' %] +[% INCLUDE 'prefs-admin-search.inc' %] + + + +
+
+
+
+ +
+ +

Upload plugin key

+
+
+
NOTE: Only PEM file format is supported.
+
    +
  1. + +
  2. +
  3. + +
  4. +
+ + +
+
+ +

Plugin keys

+ [% UNLESS ( plugin_keys ) %] +
No plugin keys are imported
+ [% ELSE %] + + + + [% IF ( CAN_user_plugins_manage ) %] + + [% END %] + + + [% FOREACH plugin_key IN plugin_keys %] + + + [% IF ( CAN_user_plugins_manage ) %] + + [% END %] + [% END %] +
NameActions
+ [% plugin_key.name | html %] + + +
+ [% END %] +
+ +
+
+ +
+ +
+
+ +[% MACRO jsinclude BLOCK %] + [% Asset.js("js/tools-menu.js") | $raw %] +[% END %] + +[% INCLUDE 'intranet-bottom.inc' %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/plugins/plugins-upload.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/plugins/plugins-upload.tt index 06daeb54f1..e6b49a6a6f 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/plugins/plugins-upload.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/plugins/plugins-upload.tt @@ -27,6 +27,7 @@ [% ELSIF ( ERROR.EMPTYUPLOAD ) %]
  • The upload file appears to be empty.
  • [% ELSIF ( ERROR.UZIPFAIL ) %]
  • [% ERROR.UZIPFAIL | html %] failed to unpack.
    Please verify the integrity of the zip file and retry.
  • [% ELSIF ( ERROR.NOWRITEPLUGINS ) %]
  • Cannot unpack file to the plugins directory.
    Please verify that the Apache user can write to the plugins directory.
  • + [% ELSIF ( ERROR.SIGNFAIL ) %]
  • The authenticity of the upload file cannot be verified.
    Please verify that you have uploaded the file's author's public key and the author's signature of the upload file.
  • [% ELSE %]
  • [% ERROR.CORERR | html %] An unknown error has occurred.
    Please review the error log for more details.
  • [% END %] [% END %]
    @@ -39,6 +40,11 @@ +
      +
    1. + +
    2. +
    diff --git a/plugins/plugins-keys.pl b/plugins/plugins-keys.pl new file mode 100644 index 0000000000..31e449d428 --- /dev/null +++ b/plugins/plugins-keys.pl @@ -0,0 +1,87 @@ +#!/usr/bin/perl + +# Copyright 2020 David Cook +# +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use CGI qw ( -utf8 ); + +use Crypt::OpenSSL::RSA; + +use Koha::Plugins::Key; +use Koha::Plugins::Keys; + +use C4::Auth; +use C4::Output; +use C4::Debug; +use C4::Context; + +my $plugins_enabled = C4::Context->config("enable_plugins"); + +my $input = new CGI; + +my ( $template, $borrowernumber, $cookie ) = get_template_and_user( + { template_name => ($plugins_enabled) ? "plugins/plugins-keys.tt" : "plugins/plugins-disabled.tt", + query => $input, + type => "intranet", + authnotrequired => 0, + flagsrequired => { plugins => '*' }, + debug => 1, + } +); + +if ($plugins_enabled) { + my $op = $input->param('op') // ''; + if ($op){ + if ($op eq "Upload"){ + my $key_name = $input->param('key_name'); + #my $uploadfilename = $input->param('uploadfile'); + my $uploadfile = $input->upload('uploadfile'); + if ($uploadfile){ + my @lines = <$uploadfile>; + my $key_string = join("",@lines); + if ($key_string){ + my $rsa_pub = Crypt::OpenSSL::RSA->new_public_key($key_string); + if ($rsa_pub){ + my $public_key = Koha::Plugins::Key->new({ + name => $key_name, + public_key => $rsa_pub->get_public_key_x509_string(), + }); + if ($public_key){ + $public_key->store(); + } + } + } + } + } + elsif ( $op eq "Delete" ){ + my $keyid = $input->param('keyid'); + if ($keyid){ + my $public_key = Koha::Plugins::Keys->find($keyid); + warn $public_key; + if ($public_key){ + $public_key->delete(); + } + } + } + } + my @plugin_keys = Koha::Plugins::Keys->as_list(); + $template->param( plugin_keys => \@plugin_keys, ); +} + +output_html_with_http_headers( $input, $cookie, $template->output ); diff --git a/plugins/plugins-upload.pl b/plugins/plugins-upload.pl index c4e8a204d0..d633677ef3 100755 --- a/plugins/plugins-upload.pl +++ b/plugins/plugins-upload.pl @@ -23,6 +23,7 @@ use CGI qw ( -utf8 ); use Mojo::UserAgent; use File::Copy; use File::Temp; +use File::Slurp; use C4::Context; use C4::Auth; @@ -30,6 +31,7 @@ use C4::Output; use C4::Members; use C4::Debug; use Koha::Plugins; +use Koha::Plugins::Keys; my $plugins_enabled = C4::Context->config("enable_plugins"); @@ -90,6 +92,26 @@ if ($plugins_enabled) { close $tfh; } + my $verified; + my $uploadsignfile = $input->param('uploadsignfile'); + if ($uploadsignfile){ + local $/ = undef; + my $signature = <$uploadsignfile>; + my $plugin_file = read_file($tempfile, { binmode => ':raw' }); + $verified = Koha::Plugins::Keys->verify_plugin({ + plugin_file => $plugin_file, + signature => $signature, + }); + } + + my $require_signature = C4::Context->preference("RequirePluginSignatures"); + if ($require_signature && ! $verified){ + $errors{SIGNFAIL} = 1; + $template->param( ERRORS => [ \%errors ] ); + output_html_with_http_headers $input, $cookie, $template->output; + exit; + } + my $ae = Archive::Extract->new( archive => $tempfile, type => 'zip' ); unless ( $ae->extract( to => $plugins_dir ) ) { warn "ERROR: " . $ae->error; -- 2.11.0