diff --git a/include/class.i18n.php b/include/class.i18n.php index 34e873ef93ee1a4e4131f629abc93c25932a2f44..c023566d90f0fc7ad154c91d004f113c232c7c51 100644 --- a/include/class.i18n.php +++ b/include/class.i18n.php @@ -217,6 +217,7 @@ class Internationalization { 'lang' => $code, 'locale' => $locale, 'path' => $f, + 'phar' => substr($f, -5) == '.phar', 'code' => $base, ); } diff --git a/include/class.plugin.php b/include/class.plugin.php index ffa86b614161cb15b9ea9b418155b1d7f2c0e208..8d09c7713f425446cd389fefd775e3c20103630d 100644 --- a/include/class.plugin.php +++ b/include/class.plugin.php @@ -309,6 +309,15 @@ abstract class Plugin { var $id; var $info; + const VERIFIED = 1; // Thumbs up + const VERIFY_EXT_MISSING = 2; // PHP extension missing + const VERIFY_FAILED = 3; // Bad signature data + const VERIFY_ERROR = 4; // Unable to verify (unexpected error) + const VERIFY_NO_KEY = 5; // Public key missing + const VERIFY_DNS_PASS = 6; // DNS check passes, cannot verify sig + + static $verify_domain = 'updates.osticket.com'; + function Plugin($id) { $this->id = $id; $this->load(); @@ -422,6 +431,129 @@ abstract class Plugin { if ($path) return PluginManager::getInstance($path); } + + /** + * Function: isVerified + * + * This will help verify the content, integrity, oversight, and origin + * of plugins, language packs and other modules distributed for + * osTicket. + * + * This idea is that the signature of the PHAR file will be registered + * in DNS, for instance, + * `7afc8bf80b0555bed88823306744258d6030f0d9.updates.osticket.com`, for + * a PHAR file with a SHA1 signature of + * `7afc8bf80b0555bed88823306744258d6030f0d9 `, which will resolve to a + * string like the following: + * ``` + * "v=1; i=storage:s3; s=MEUCIFw6A489eX4Oq17BflxCZ8+MH6miNjtcpScUoKDjmblsAiEAjiBo9FzYtV3WQtW6sbhPlJXcoPpDfYyQB+BFVBMps4c=; V=0.1;" + * ``` + * Which is a simple semicolon separated key-value pair string with the + * following keys + * + * Key | Description + * :----|:--------------------------------------------------- + * v | Algorithm version + * i | Plugin 'id' registered in plugin.php['id'] + * V | Plugin 'version' registered in plugin.php['version'] + * s | OpenSSL signature of the PHAR SHA1 signature using a + * | private key (specified on the command line) + * + * The public key, which will be distributed with osTicket, can be used + * to verify the signature of the PHAR file from the data received from + * DNS. + * + * Parameters: + * $phar - (string) filename of phar file to verify + * + * Returns: + * (int) - + * Plugin::VERIFIED upon success + * Plugin::VERIFY_DNS_PASS if found in DNS but cannot verify sig + * Plugin::VERIFY_NO_KEY if public key not found in include/plugins + * Plugin::VERIFY_FAILED if the plugin fails validation + * Plugin::VERIFY_EXT_MISSING if a PHP extension is required + * Plugin::VERIFY_ERROR if an unexpected error occurred + */ + static function isVerified($phar) { + static $pubkey = null; + + if (!class_exists('Phar')) + return self::VERIFY_EXT_MISSING; + elseif (!file_exists(INCLUDE_DIR . '/plugins/updates.pem')) + return self::VERIFY_NO_KEY; + + if (!isset($pubkey)) { + $pubkey = openssl_pkey_get_public( + file_get_contents(INCLUDE_DIR . 'plugins/updates.pem')); + } + if (!$pubkey) { + return self::VERIFY_ERROR; + } + + require_once(PEAR_DIR.'Net/DNS2.php'); + $P = new Phar($phar); + $sig = $P->getSignature(); + $info = array(); + try { + $q = new Net_DNS2_Resolver(); + $r = $q->query(strtolower($sig['hash']) . '.' . self::$verify_domain, 'TXT'); + foreach ($r->answer as $rec) { + foreach ($rec->text as $txt) { + foreach (explode(';', $txt) as $kv) { + list($k, $v) = explode('=', trim($kv)); + $info[$k] = trim($v); + } + if ($info['v'] && $info['s']) + break; + } + } + } + catch (Net_DNS2_Exception $e) { + // TODO: Differenciate NXDOMAIN and DNS failure + } + + if (is_array($info) && isset($info['v'])) { + switch ($info['v']) { + case '1': + if (!($signature = base64_decode($info['s']))) + return self::VERIFY_FAILED; + elseif (!function_exists('openssl_verify')) + return self::VERIFY_DNS_PASS; + + $codes = array( + -1 => self::VERIFY_ERROR, + 0 => self::VERIFY_FAILED, + 1 => self::VERIFIED, + ); + $result = openssl_verify($sig['hash'], $signature, $pubkey, + OPENSSL_ALGO_SHA1); + return $codes[$result]; + } + } + return self::VERIFY_FAILED; + } + + static function showVerificationBadge($phar) { + switch (self::isVerified($phar)) { + case self::VERIFIED: + $show_lock = true; + case self::VERIFY_DNS_PASS: ?> + + <span class="label label-verified" title="<?php + if ($show_lock) echo sprintf(__('Verified by %s'), self::$verify_domain); + ?>"> <?php + if ($show_lock) echo '<i class="icon icon-lock"></i>'; ?> + <?php echo $show_lock ? __('Verified') : __('Registered'); ?></span> +<?php break; + case self::VERIFY_FAILED: ?> + + <span class="label label-danger" title="<?php + echo __('The originator of this extension cannot be verified'); + ?>"><i class="icon icon-warning-sign"></i></span> +<?php break; + } + } } ?> diff --git a/include/plugins/updates.pem b/include/plugins/updates.pem new file mode 100644 index 0000000000000000000000000000000000000000..9f4077be363d71b3615867622fd1cf1cf899499f --- /dev/null +++ b/include/plugins/updates.pem @@ -0,0 +1,20 @@ +-----BEGIN PUBLIC KEY----- +MIIDRjCCAjkGByqGSM44BAEwggIsAoIBAQCdMklcYXqGlNYXZ5bS808qOS6U9S5z +IQcCrf2Hzs6OLmTUDkLxKuvmoBVMu7Tkbb6TY4ne+G9npWih4OfpVsvY22T13sf2 +EBcX0jOslcm+Bc5eN4dmgjs17iuf14oMkM8WdlVceT1tVqfKJnJm3i3U/+x5SDUY +x6UhbOgMygemfIoFtqTbaMvAmype8HnflIxRoL25uZ44Hx7eef6zpOqYVXM8VQq3 +RNXXfNmoxiMNhVrSK18LE8D8h4ABMzDg/pxFt2fbf2IrNFAV+h2MSfF+ueLcrEfy +XbtWLx7DdxqASEASwVLGm3vJslLBBvBDfGhTSNMVGk1XWBIeHfsALPV9AiEA39Qn +ksDDQIL5Ed7Q9mYHDqM23mtJPxi2L478HmU3zY8CggEAE1UcB7QrR/bLWgzX/fbp +xzVonCJElkxrx1rviKkjwAAAPurCFy2bQNRPMp/e7DFVwAtouQf3i2JWtRNeyHOC +dxDKrspfCDOdovRHkWYOxXJCztesMGcUAHo/WmsM+Qb0WobAG9MnZ5AEDldSOBrM +VyJfEuoF12EPsbOUYjVzJz1swIWgrqZlo1ZKD/oC4Wx0/zXz+5gWWbgXykTWE4wV +PzU8r33qkgiEtXOjMc5YbvWmTcM0xw7OH34LPOtgUNZtcYSK2u4p1NQ+bDFpXar5 +MEmfmILYFBxGyoe1tCut0M6ulzzV8iBhWHecGEx09Ln3wfoJE+ba0PNn4bdJm6T6 +QQOCAQUAAoIBADPF6xGfYIrIPqiJaeHzTU/q4zpKRCGcjw1chtsNn+oZQzNqvWbI +XNu7E+MBGimgYerJzyx7lE5bfyu+C4CS6acOutX3ujYfHRVkkkyJedv8q5Ky8kJk +OjyyhS+cAszbQdh/zvBu6SoDa50mcmk/jfgiRZT0FiSNBJD5nlgjyo2cTEK7e2oR +GD2N7l43M9BuNjUjQqgeRO9RMt6g4iRO/+KlC/yJrSy/PrLARatk/21ZbCn8jofi +WR3uNkh7bT7dIfJDDmLsRuQ5fegdQ9mQ/7nLvMZha4pitwTlaI6P0c76fRN1Al27 +6LpcuPd1iHi4UjnvGR5nRwVN68igLNp2tGY= +-----END PUBLIC KEY----- diff --git a/include/staff/system.inc.php b/include/staff/system.inc.php index 89f3bd498c80eb355f0d455af192701ec02e9a76..6ca862558ff275812c6210921498a034bca7d925 100644 --- a/include/staff/system.inc.php +++ b/include/staff/system.inc.php @@ -82,3 +82,24 @@ $commit = GIT_VERSION != '$git' ? GIT_VERSION : ( echo sprintf('%.2f MiB', $space); ?></td> </tbody> </table> +<br/> +<h2><?php echo __('Installed Language Packs'); ?></h2> +<div style="margin: 0 20px"> +<?php + foreach (Internationalization::availableLanguages() as $info) { + $p = $info['path']; + if ($info['phar']) $p = 'phar://' . $p; + if (file_exists($p . '/MANIFEST.php')) { + $manifest = (include $p . '/MANIFEST.php'); ?> + <h3><strong><?php echo Internationalization::getLanguageDescription($info['code']); ?></strong> + — <?php echo $manifest['Language']; ?> +<?php if ($info['phar']) + Plugin::showVerificationBadge($info['path']); + ?> + </h3> + <div><?php echo __('Version'); ?>: <?php echo $manifest['Version']; ?>, + <?php echo __('Built'); ?>: <?php echo $manifest['Build-Date']; ?> + </div> +<?php } + } ?> +</div> diff --git a/js/osticket.js b/js/osticket.js index ce9d8d7fbdf0aee54198addf39c81b395238e8ad..ae166d4e3a2d9dadb8747639ed99f388491a7174 100644 --- a/js/osticket.js +++ b/js/osticket.js @@ -216,7 +216,7 @@ showImagesInline = function(urls, thread_id) { } function __(s) { - if ($.strings && $.strings[s]) - return $.strings[s]; + if ($.oststrings && $.oststrings[s]) + return $.oststrings[s]; return s; } diff --git a/scp/css/scp.css b/scp/css/scp.css index 8e9f190386f3bdefcf799299b9ac419ddcb1c9bc..11a647993974a6617a2a0bf760426c94405843ee 100644 --- a/scp/css/scp.css +++ b/scp/css/scp.css @@ -1804,6 +1804,20 @@ tr.disabled th { .label-info { background-color: #3a87ad; } +.label-verified { + border:1px solid green; + background-color:transparent; + background-color:rgba(0,0,0,0); + color:green; + text-shadow:none; +} +.label-danger { + border:1px solid red; + background-color:transparent; + background-color:rgba(0,0,0,0); + color:red; + text-shadow:none; +} .tab_content { position: relative; diff --git a/scp/js/scp.js b/scp/js/scp.js index 2a7a08fcfb924e416018610d37704f2b9162b2b0..df8a867564757f14544ec1742b74a1a039948694 100644 --- a/scp/js/scp.js +++ b/scp/js/scp.js @@ -771,7 +771,7 @@ $('#new-note').live('click', function() { }); function __(s) { - if ($.strings && $.strings[s]) - return $.strings[s]; + if ($.oststrings && $.oststrings[s]) + return $.oststrings[s]; return s; } diff --git a/setup/cli/modules/i18n.php b/setup/cli/modules/i18n.php index 9f2d90b3eb3e7a06663059d9e8d9a08ba070fc0f..e4084500d25c398327f3c76e0bfc4b4f5a6822e5 100644 --- a/setup/cli/modules/i18n.php +++ b/setup/cli/modules/i18n.php @@ -15,6 +15,7 @@ class i18n_Compiler extends Module { 'list' => 'Show list of available translations', 'build' => 'Compile a language pack', 'make-pot' => 'Build the PO file for gettext translations', + 'sign' => 'Sign a language pack', ), ), ); @@ -25,6 +26,10 @@ class i18n_Compiler extends Module { CROWDIN_API_KEY is defined in the ost-config.php file'), "lang" => array('-L', '--lang', 'metavar'=>'code', 'help'=>'Language code (used for building)'), + 'file' => array('-f', '--file', 'metavar'=>'FILE', + 'help' => "Language pack to be signed"), + 'pkey' => array('-P', '--pkey', 'metavar'=>'key-file', + 'help' => 'Private key for signing'), ); static $crowdin_api_url = 'http://i18n.osticket.com/api/project/osticket-official/{command}'; @@ -81,6 +86,11 @@ class i18n_Compiler extends Module { case 'make-pot': $this->_make_pot(); break; + case 'sign': + if (!$options['file'] || !file_exists($options['file'])) + $this->fail('Specify a language pack to sign with --file='); + $this->_sign($options['file'], $options); + break; } } @@ -211,18 +221,67 @@ class i18n_Compiler extends Module { } $phar->addFromString( 'js/osticket-strings.js', - sprintf('(function($){$.strings=%s;})(jQuery);', + sprintf('(function($){$.oststrings=%s;})(jQuery);', JsonDataEncoder::encode($phrases)) ); } + list($code, $zip) = $this->_request("download/$lang.zip"); + + // Include a manifest + include_once INCLUDE_DIR . 'class.mailfetch.php'; + + $po_header = Mail_Parse::splitHeaders($mo['']); + $info = array( + 'Build-Date' => date(DATE_RFC822), + 'Build-Version' => trim(`git describe`), + 'Language' => $po_header['Language'], + #'Phrases' => + #'Translated' => + #'Approved' => + 'Id' => 'lang:' . $lang, + 'Version' => strtotime($po_header['PO-Revision-Date']), + ); + $phar->addFromString( + 'MANIFEST.php', + sprintf('<?php return %s;', var_export($info, true))); + // TODO: Sign files // Use a very small stub $phar->setStub('<?php __HALT_COMPILER();'); + $phar->setSignatureAlgorithm(Phar::SHA1); $phar->stopBuffering(); } + function _sign($plugin, $options) { + if (!file_exists($plugin)) + $this->fail($plugin.': Cannot find file'); + elseif (!file_exists("phar://$plugin/MANIFEST.php")) + $this->fail($plugin.': Should be a plugin PHAR file'); + $info = (include "phar://$plugin/MANIFEST.php"); + $phar = new Phar($plugin); + + if (!function_exists('openssl_get_privatekey')) + $this->fail('OpenSSL extension required for signing'); + $private = openssl_get_privatekey( + file_get_contents($options['pkey'])); + if (!$private) + $this->fail('Unable to read private key'); + $signature = $phar->getSignature(); + $seal = ''; + openssl_sign($signature['hash'], $seal, $private, + OPENSSL_ALGO_SHA1); + if (!$seal) + $this->fail('Unable to generate verify signature'); + + $this->stdout->write(sprintf("Signature: %s\n", + strtolower($signature['hash']))); + $this->stdout->write( + sprintf("Seal: \"v=1; i=%s; s=%s; V=%s;\"\n", + $info['Id'], base64_encode($seal), $info['Version'])); + } + function __read_next_string($tokens) { $string = array();