diff --git a/app/Lib/Tools/CryptGpgExtended.php b/app/Lib/Tools/CryptGpgExtended.php index 343bcb7ac..e35e6a556 100644 --- a/app/Lib/Tools/CryptGpgExtended.php +++ b/app/Lib/Tools/CryptGpgExtended.php @@ -61,4 +61,91 @@ class CryptGpgExtended extends Crypt_GPG return $keyData; } + + /** + * Return key info without importing it. + * + * @param string $key + * @return Crypt_GPG_Key[] + * @throws Crypt_GPG_Exception + * @throws Crypt_GPG_InvalidOperationException + */ + public function keyInfo($key) + { + $input = $this->_prepareInput($key, false, false); + + $output = ''; + $this->engine->reset(); + $this->engine->setInput($input); + $this->engine->setOutput($output); + $this->engine->setOperation('--import', ['--import-options', 'show-only', '--with-colons']); + $this->engine->run(); + + $keys = array(); + $key = null; // current key + $subKey = null; // current sub-key + + foreach (explode(PHP_EOL, $output) as $line) { + $lineExp = explode(':', $line); + + if ($lineExp[0] === 'pub') { + // new primary key means last key should be added to the array + if ($key !== null) { + $keys[] = $key; + } + + $key = new Crypt_GPG_Key(); + + $subKey = Crypt_GPG_SubKey::parse($line); + $key->addSubKey($subKey); + + } elseif ($lineExp[0] === 'sub') { + $subKey = Crypt_GPG_SubKey::parse($line); + $key->addSubKey($subKey); + + } elseif ($lineExp[0] === 'fpr') { + $fingerprint = $lineExp[9]; + + // set current sub-key fingerprint + $subKey->setFingerprint($fingerprint); + + } elseif ($lineExp[0] === 'uid') { + $string = stripcslashes($lineExp[9]); // as per documentation + $userId = new Crypt_GPG_UserId($string); + + if ($lineExp[1] === 'r') { + $userId->setRevoked(true); + } + + $key->addUserId($userId); + } + } + + // add last key + if ($key !== null) { + $keys[] = $key; + } + + return $keys; + } + + /** + * @param string $key + * @return string + * @throws Crypt_GPG_Exception + * @throws Crypt_GPG_InvalidOperationException + */ + public function enarmor($key) + { + $input = $this->_prepareInput($key, false, false); + + $armored = ''; + $this->engine->reset(); + $this->engine->setInput($input); + $this->engine->setOutput($armored); + $this->engine->setOperation('--enarmor'); + $this->engine->run(); + + return $armored; + } } diff --git a/app/Lib/Tools/GpgTool.php b/app/Lib/Tools/GpgTool.php index 5a5d28dd7..38b305199 100644 --- a/app/Lib/Tools/GpgTool.php +++ b/app/Lib/Tools/GpgTool.php @@ -5,7 +5,7 @@ class GpgTool * @return CryptGpgExtended * @throws Exception */ - public function initializeGpg() + public static function initializeGpg() { if (!class_exists('Crypt_GPG')) { // 'Crypt_GPG' class cannot be autoloaded, try to require from include_path. @@ -31,6 +31,14 @@ class GpgTool return new CryptGpgExtended($options); } + /** @var CryptGpgExtended */ + private $gpg; + + public function __construct($gpg) + { + $this->gpg = $gpg; + } + /** * @param string $search * @return array @@ -65,9 +73,41 @@ class GpgTool $key = $response->body; + if ($this->gpg) { + $fetchedFingerprint = $this->validateGpgKey($key); + if (strtolower($fingerprint) !== strtolower($fetchedFingerprint)) { + throw new Exception("Requested fingerprint do not match with fetched key fingerprint ($fingerprint != $fetchedFingerprint)"); + } + } + return $key; } + /** + * Validates PGP key + * @param string $keyData + * @return string Primary key fingerprint + * @throws Exception + */ + public function validateGpgKey($keyData) + { + if (!$this->gpg instanceof CryptGpgExtended) { + throw new InvalidArgumentException("Valid CryptGpgExtended instance required."); + } + $fetchedKeyInfo = $this->gpg->keyInfo($keyData); + if (empty($fetchedKeyInfo)) { + throw new Exception("No key found"); + } + if (count($fetchedKeyInfo) !== 1) { + throw new Exception("Multiple keys found"); + } + $primaryKey = $fetchedKeyInfo[0]->getPrimaryKey(); + if (empty($primaryKey)) { + throw new Exception("No primary key found"); + } + return $primaryKey->getFingerprint(); + } + /** * @param string $body * @return array @@ -107,6 +147,91 @@ class GpgTool return $final; } + /** + * @see https://tools.ietf.org/html/draft-koch-openpgp-webkey-service-10 + * @param string $email + * @return string + * @throws Exception + */ + public function wkd($email) + { + if (!$this->gpg instanceof CryptGpgExtended) { + throw new InvalidArgumentException("Valid CryptGpgExtended instance required."); + } + + $parts = explode('@', $email); + if (count($parts) !== 2) { + throw new InvalidArgumentException("Invalid e-mail address provided."); + } + + list($localPart, $domain) = $parts; + $localPart = strtolower($localPart); + $localPartHash = $this->zbase32(sha1($localPart, true)); + + $advancedUrl = "https://openpgpkey.$domain/.well-known/openpgpkey/" . strtolower($domain) . "/hu/$localPartHash"; + try { + $response = $this->keyServerLookup($advancedUrl); + return $this->processWkdResponse($response); + } catch (Exception $e) { + // pass, continue to direct method + } + + $directUrl = "https://$domain/.well-known/openpgpkey/hu/$localPartHash"; + $response = $this->keyServerLookup($directUrl); + return $this->processWkdResponse($response); + } + + /** + * @param HttpSocketResponse $response + * @return string + * @throws Crypt_GPG_Exception + * @throws Crypt_GPG_InvalidOperationException + */ + private function processWkdResponse(HttpSocketResponse $response) + { + if ($response->code == 404) { + throw new NotFoundException("Key not found"); + } else if (!$response->isOk()) { + throw new Exception("Fetching the WKD failed with HTTP error {$response->code}: {$response->reasonPhrase}"); + } + + return $this->gpg->enarmor($response->body()); + } + + /** + * Converts data to zbase32 string. + * + * @see http://philzimmermann.com/docs/human-oriented-base-32-encoding.txt + * @param string $data + * @return string + */ + private function zbase32($data) + { + $chars = 'ybndrfg8ejkmcpqxot1uwisza345h769'; // lower-case + $res = ''; + $remainder = 0; + $remainderSize = 0; + + for ($i = 0; $i < strlen($data); $i++) { + $b = ord($data[$i]); + $remainder = ($remainder << 8) | $b; + $remainderSize += 8; + while ($remainderSize > 4) { + $remainderSize -= 5; + $c = $remainder & (31 << $remainderSize); + $c >>= $remainderSize; + $res .= $chars[$c]; + } + } + if ($remainderSize > 0) { + // remainderSize < 5: + $remainder <<= (5 - $remainderSize); + $c = $remainder & 31; + $res .= $chars[$c]; + } + return $res; + } + /** * @param string $uri * @return HttpSocketResponse diff --git a/app/Model/Server.php b/app/Model/Server.php index 87f3fa4cd..8dc49de35 100644 --- a/app/Model/Server.php +++ b/app/Model/Server.php @@ -5202,9 +5202,8 @@ class Server extends AppModel $gpgStatus = 0; if (Configure::read('GnuPG.email') && Configure::read('GnuPG.homedir')) { $continue = true; - $gpgTool = new GpgTool(); try { - $gpg = $gpgTool->initializeGpg(); + $gpg = GpgTool::initializeGpg(); } catch (Exception $e) { $this->logException("Error during initializing GPG.", $e, LOG_NOTICE); $gpgStatus = 2; diff --git a/app/Model/User.php b/app/Model/User.php index 17ce40b7b..5b8054b42 100644 --- a/app/Model/User.php +++ b/app/Model/User.php @@ -222,7 +222,7 @@ class User extends AppModel 'Containable' ); - /** @var Crypt_GPG|null|false */ + /** @var CryptGpgExtended|null|false */ private $gpg; public function beforeValidate($options = array()) @@ -302,9 +302,11 @@ class User extends AppModel return true; } - // Checks if the GnuPG key is a valid key, but also import it in the keychain. - // this will NOT fail on keys that can only be used for signing but not encryption! - // the method in verifyUsers will fail in that case. + /** + * Checks if the GnuPG key is a valid key. + * @param array $check + * @return bool + */ public function validateGpgkey($check) { // LATER first remove the old gpgkey from the keychain @@ -319,12 +321,11 @@ class User extends AppModel return true; } try { - $keyImportOutput = $gpg->importKey($check['gpgkey']); - if (!empty($keyImportOutput['fingerprint'])) { - return true; - } + $gpgTool = new GpgTool($gpg); + $gpgTool->validateGpgKey($check['gpgkey']); + return true; } catch (Exception $e) { - $this->logException("Exception during importing GPG key", $e); + $this->logException("Exception during validating GPG key", $e, LOG_NOTICE); return false; } } @@ -452,25 +453,40 @@ class User extends AppModel ))); } - public function verifySingleGPG($user, $gpg = null) + /** + * 0 - true if key is valid + * 1 - User e-mail + * 2 - Error message + * 3 - Not used + * 4 - Key fingerprint + * 5 - Key fingerprint + * @param array $user + * @return array + */ + public function verifySingleGPG(array $user) { - if ($gpg === null) { - $gpg = $this->initializeGpg(); - if (!$gpg) { - $result[2] = 'GnuPG is not configured on this system.'; - $result[0] = true; - return $result; - } + $result = [0 => false, 1 => $user['User']['email']]; + + $gpg = $this->initializeGpg(); + if (!$gpg) { + $result[2] = 'GnuPG is not configured on this system.'; + return $result; } - $result = array(); + try { $currentTimestamp = time(); - $temp = $gpg->importKey($user['User']['gpgkey']); - $key = $gpg->getKeys($temp['fingerprint']); - $result[5] = $temp['fingerprint']; - $subKeys = $key[0]->getSubKeys(); - $sortedKeys = array('valid' => 0, 'expired' => 0, 'noEncrypt' => 0); - foreach ($subKeys as $subKey) { + $keys = $gpg->keyInfo($user['User']['gpgkey']); + if (count($keys) !== 1) { + $result[2] = 'Multiple or no key found'; + return $result; + } + + $key = $keys[0]; + $result[4] = $key->getPrimaryKey()->getFingerprint(); + $result[5] = $result[4]; + + $sortedKeys = ['valid' => 0, 'expired' => 0, 'noEncrypt' => 0]; + foreach ($key->getSubKeys() as $subKey) { $expiration = $subKey->getExpirationDate(); if ($expiration != 0 && $currentTimestamp > $expiration) { $sortedKeys['expired']++; @@ -490,14 +506,12 @@ class User extends AppModel if ($sortedKeys['noEncrypt']) { $result[2] .= ' Found ' . $sortedKeys['noEncrypt'] . ' subkey(s) that are sign only.'; } + } else { $result[0] = true; } } catch (Exception $e) { $result[2] = $e->getMessage(); - $result[0] = true; } - $result[1] = $user['User']['email']; - $result[4] = $temp['fingerprint']; return $result; } @@ -521,7 +535,7 @@ class User extends AppModel } $results = []; foreach ($users as $k => $user) { - $results[$user['User']['id']] = $this->verifySingleGPG($user, $gpg); + $results[$user['User']['id']] = $this->verifySingleGPG($user); } return $results; } @@ -817,7 +831,7 @@ class User extends AppModel */ public function searchGpgKey($email) { - $gpgTool = new GpgTool(); + $gpgTool = new GpgTool(null); return $gpgTool->searchGpgKey($email); } @@ -828,7 +842,7 @@ class User extends AppModel */ public function fetchGpgKey($fingerprint) { - $gpgTool = new GpgTool(); + $gpgTool = new GpgTool($this->initializeGpg()); return $gpgTool->fetchGpgKey($fingerprint); } @@ -1313,7 +1327,7 @@ class User extends AppModel /** * Initialize GPG. Returns `null` if initialization failed. * - * @return null|Crypt_GPG + * @return null|CryptGpgExtended */ private function initializeGpg() { @@ -1326,8 +1340,7 @@ class User extends AppModel } try { - $gpgTool = new GpgTool(); - $this->gpg = $gpgTool->initializeGpg(); + $this->gpg = GpgTool::initializeGpg(); return $this->gpg; } catch (Exception $e) { $this->logException("GPG couldn't be initialized, GPG encryption and signing will be not available.", $e, LOG_NOTICE); diff --git a/app/View/Users/verify_g_p_g.ctp b/app/View/Users/verify_g_p_g.ctp index 759752b2d..7a16ba481 100644 --- a/app/View/Users/verify_g_p_g.ctp +++ b/app/View/Users/verify_g_p_g.ctp @@ -3,7 +3,7 @@