From 41a241cadad2c9b8d0be6dc3ac575674e52ef495 Mon Sep 17 00:00:00 2001 From: iglocska Date: Fri, 21 Oct 2022 15:25:52 +0200 Subject: [PATCH] new: [pgp] library ported from MISP - added proper view elements for encryption keys - added key information extraction --- composer.json | 13 +- src/Controller/EncryptionKeysController.php | 32 ++- src/Lib/Tools/CryptGpgExtended.php | 199 ++++++++++++++ src/Lib/Tools/GpgTool.php | 248 ++++++++++++++++++ src/Model/Table/EncryptionKeysTable.php | 91 +++++++ templates/EncryptionKeys/index.php | 11 +- templates/EncryptionKeys/view.php | 81 +++--- .../IndexTable/Fields/pgp_key.php | 5 + .../SingleViews/Fields/keyField.php | 5 + templates/element/genericElements/key.php | 21 ++ 10 files changed, 670 insertions(+), 36 deletions(-) create mode 100644 src/Lib/Tools/CryptGpgExtended.php create mode 100644 src/Lib/Tools/GpgTool.php create mode 100644 templates/element/genericElements/IndexTable/Fields/pgp_key.php create mode 100644 templates/element/genericElements/SingleViews/Fields/keyField.php create mode 100644 templates/element/genericElements/key.php diff --git a/composer.json b/composer.json index 17e726e..6e2a48d 100644 --- a/composer.json +++ b/composer.json @@ -13,11 +13,12 @@ "cakephp/migrations": "^3.0", "cakephp/plugin-installer": "^1.2", "erusev/parsedown": "^1.7", - "mobiledetect/mobiledetectlib": "^2.8" + "mobiledetect/mobiledetectlib": "^2.8", + "pear/crypt_gpg": "^1.6" }, "require-dev": { "cakephp/bake": "^2.0.3", - "cakephp/cakephp-codesniffer": "~4.0.0", + "cakephp/cakephp-codesniffer": "^4.0", "cakephp/debug_kit": "^4.0", "cebe/php-openapi": "^1.6", "fzaninotto/faker": "^1.9", @@ -68,7 +69,11 @@ }, "prefer-stable": true, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true, + "cakephp/plugin-installer": true + } }, "minimum-stability": "dev" -} \ No newline at end of file +} diff --git a/src/Controller/EncryptionKeysController.php b/src/Controller/EncryptionKeysController.php index d0376db..39453b2 100644 --- a/src/Controller/EncryptionKeysController.php +++ b/src/Controller/EncryptionKeysController.php @@ -21,6 +21,8 @@ class EncryptionKeysController extends AppController public function index() { + $this->EncryptionKeys->initializeGpg(); + $Model = $this->EncryptionKeys; $this->CRUD->index([ 'quickFilters' => $this->quickFilterFields, 'filters' => $this->filterFields, @@ -31,6 +33,20 @@ class EncryptionKeysController extends AppController ], 'contain' => $this->containFields, 'statisticsFields' => $this->statisticsFields, + 'afterFind' => function($data) use ($Model) { + if ($data['type'] === 'pgp') { + $keyInfo = $Model->verifySingleGPG($data); + $data['status'] = __('OK'); + $data['fingerprint'] = __('N/A'); + if (!$keyInfo[0]) { + $data['status'] = $keyInfo[2]; + } + if (!empty($keyInfo[4])) { + $data['fingerprint'] = $keyInfo[4]; + } + } + return $data; + } ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { @@ -155,8 +171,22 @@ class EncryptionKeysController extends AppController public function view($id = false) { + $this->EncryptionKeys->initializeGpg(); + $Model = $this->EncryptionKeys; $this->CRUD->view($id, [ - 'contain' => ['Individuals', 'Organisations'] + 'contain' => ['Individuals', 'Organisations'], + 'afterFind' => function($data) use ($Model) { + if ($data['type'] === 'pgp') { + $keyInfo = $Model->verifySingleGPG($data); + if (!$keyInfo[0]) { + $data['pgp_error'] = $keyInfo[2]; + } + if (!empty($keyInfo[4])) { + $data['pgp_fingerprint'] = $keyInfo[4]; + } + } + return $data; + } ]); $responsePayload = $this->CRUD->getResponsePayload(); if (!empty($responsePayload)) { diff --git a/src/Lib/Tools/CryptGpgExtended.php b/src/Lib/Tools/CryptGpgExtended.php new file mode 100644 index 0000000..266f6f7 --- /dev/null +++ b/src/Lib/Tools/CryptGpgExtended.php @@ -0,0 +1,199 @@ +getFileName(); + throw new Exception("Crypt_GPG class from '$classPath' is too old, at least version 1.6.1 is required."); + } + parent::__construct($options); + } + + /** + * Export the smallest public key possible from the keyring. + * + * This removes all signatures except the most recent self-signature on each user ID. This option is the same as + * running the --edit-key command "minimize" before export except that the local copy of the key is not modified. + * + * The exported key remains on the keyring. To delete the public key, use + * {@link Crypt_GPG::deletePublicKey()}. + * + * If more than one key fingerprint is available for the specified + * $keyId (for example, if you use a non-unique uid) only the + * first public key is exported. + * + * @param string $keyId either the full uid of the public key, the email + * part of the uid of the public key or the key id of + * the public key. For example, + * "Test User (example) ", + * "test@example.com" or a hexadecimal string. + * @param boolean $armor optional. If true, ASCII armored data is returned; + * otherwise, binary data is returned. Defaults to + * true. + * + * @return string the public key data. + * + * @throws Crypt_GPG_KeyNotFoundException if a public key with the given + * $keyId is not found. + * + * @throws Crypt_GPG_Exception if an unknown or unexpected error occurs. + * Use the debug option and file a bug report if these + * exceptions occur. + */ + public function exportPublicKeyMinimal($keyId, $armor = true) + { + $fingerprint = $this->getFingerprint($keyId); + + if ($fingerprint === null) { + throw new \Crypt_GPG_KeyNotFoundException( + 'Key not found: ' . $keyId, + self::ERROR_KEY_NOT_FOUND, + $keyId + ); + } + + $keyData = ''; + $operation = '--export'; + $operation .= ' ' . escapeshellarg($fingerprint); + + $arguments = array('--export-options', 'export-minimal'); + if ($armor) { + $arguments[] = '--armor'; + } + + $this->engine->reset(); + $this->engine->setPins($this->passphrases); + $this->engine->setOutput($keyData); + $this->engine->setOperation($operation, $arguments); + $this->engine->run(); + + return $keyData; + } + + /** + * Return key info without importing it when GPG supports --import-options show-only, otherwise just import and + * then return details. + * + * @param string $key + * @return Crypt_GPG_Key[] + * @throws Crypt_GPG_Exception + * @throws Crypt_GPG_InvalidOperationException + */ + public function keyInfo($key) + { + $version = $this->engine->getVersion(); + if (version_compare($version, '2.1.23', 'le')) { + $importResult = $this->importKey($key); + $keys = []; + foreach ($importResult['fingerprints'] as $fingerprint) { + foreach ($this->getKeys($fingerprint) as $key) { + $keys[] = $key; + } + } + return $keys; + } + + $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 = []; + $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; + } else { + throw new \Crypt_GPG_Exception("Key data provided, but gpg process output could not be parsed: $output"); + } + + 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; + } + + /** + * @param mixed $data + * @param bool $isFile + * @param bool $allowEmpty + * @return resource|string|null + * @throws Crypt_GPG_FileException + * @throws Crypt_GPG_NoDataException + */ + protected function _prepareInput($data, $isFile = false, $allowEmpty = true) + { + if ($isFile && $data instanceof TmpFileTool) { + return $data->resource(); + } + + return parent::_prepareInput($data, $isFile, $allowEmpty); + } +} diff --git a/src/Lib/Tools/GpgTool.php b/src/Lib/Tools/GpgTool.php new file mode 100644 index 0000000..e6da4b1 --- /dev/null +++ b/src/Lib/Tools/GpgTool.php @@ -0,0 +1,248 @@ + $homedir, + 'gpgconf' => Configure::read('GnuPG.gpgconf'), + 'binary' => Configure::read('GnuPG.binary') ?: '/usr/bin/gpg', + ]; + return new CryptGpgExtended($options); + } + + public function __construct(CryptGpgExtended $gpg = null) + { + $this->gpg = $gpg; + } + + /** + * @param string $search + * @return array + * @throws Exception + */ + public function searchGpgKey($search) + { + $uri = 'https://openpgp.circl.lu/pks/lookup?search=' . urlencode($search) . '&op=index&fingerprint=on&options=mr'; + try { + $response = $this->keyServerLookup($uri); + } catch (HttpSocketHttpException $e) { + if ($e->getCode() === 404) { + return []; + } + throw $e; + } + return $this->extractKeySearch($response->body); + } + + /** + * @param string $fingerprint + * @return string|null + * @throws Exception + */ + public function fetchGpgKey($fingerprint) + { + $uri = 'https://openpgp.circl.lu/pks/lookup?search=0x' . urlencode($fingerprint) . '&op=get&options=mr'; + try { + $response = $this->keyServerLookup($uri); + } catch (HttpSocketHttpException $e) { + if ($e->getCode() === 404) { + return null; + } + throw $e; + } + + $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 (count($fetchedKeyInfo) !== 1) { + throw new Exception("Multiple keys found"); + } + $primaryKey = $fetchedKeyInfo[0]->getPrimaryKey(); + if (empty($primaryKey)) { + throw new Exception("No primary key found"); + } + $this->gpg->importKey($keyData); + return $primaryKey->getFingerprint(); + } + + /** + * @param string $body + * @return array + */ + private function extractKeySearch($body) + { + $final = array(); + $lines = explode("\n", $body); + foreach ($lines as $line) { + $parts = explode(":", $line); + + if ($parts[0] === 'pub') { + if (!empty($temp)) { + $final[] = $temp; + $temp = array(); + } + + if (strpos($parts[6], 'r') !== false || strpos($parts[6], 'd') !== false || strpos($parts[6], 'e') !== false) { + continue; // skip if key is expired, revoked or disabled + } + + $temp = array( + 'fingerprint' => $parts[1], + 'key_id' => substr($parts[1], -8), + 'date' => date('Y-m-d', $parts[4]), + ); + + } else if ($parts[0] === 'uid' && !empty($temp)) { + $temp['address'] = urldecode($parts[1]); + } + } + + if (!empty($temp)) { + $final[] = $temp; + } + + 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->gpg->enarmor($response->body()); + } catch (Exception $e) { + // pass, continue to direct method + } + + $directUrl = "https://$domain/.well-known/openpgpkey/hu/$localPartHash"; + try { + $response = $this->keyServerLookup($directUrl); + } catch (HttpSocketHttpException $e) { + if ($e->getCode() === 404) { + throw new NotFoundException("Key not found"); + } + throw $e; + } + 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 HttpSocketResponseExtended + * @throws HttpSocketHttpException + * @throws Exception + */ + private function keyServerLookup($uri) + { + App::uses('SyncTool', 'Tools'); + $syncTool = new SyncTool(); + $HttpSocket = $syncTool->createHttpSocket(['compress' => true]); + $response = $HttpSocket->get($uri); + if (!$response->isOk()) { + throw new HttpSocketHttpException($response, $uri); + } + return $response; + } +} diff --git a/src/Model/Table/EncryptionKeysTable.php b/src/Model/Table/EncryptionKeysTable.php index 2008e0d..0015e1d 100644 --- a/src/Model/Table/EncryptionKeysTable.php +++ b/src/Model/Table/EncryptionKeysTable.php @@ -10,6 +10,9 @@ use ArrayObject; class EncryptionKeysTable extends AppTable { + + public $gpg = null; + public function initialize(array $config): void { parent::initialize($config); @@ -56,4 +59,92 @@ class EncryptionKeysTable extends AppTable ->requirePresence(['type', 'encryption_key', 'owner_id', 'owner_model'], 'create'); return $validator; } + + /** + * 0 - true if key is valid + * 1 - User e-mail + * 2 - Error message + * 3 - Not used + * 4 - Key fingerprint + * 5 - Key fingerprint + * @param \App\Model\Entity\EncryptionKey $encryptionKey + * @return array + */ + public function verifySingleGPG(\App\Model\Entity\EncryptionKey $encryptionKey): array + { + $result = [0 => false, 1 => null]; + + $gpg = $this->initializeGpg(); + if (!$gpg) { + $result[2] = 'GnuPG is not configured on this system.'; + return $result; + } + + try { + $currentTimestamp = time(); + $keys = $gpg->keyInfo($encryptionKey['encryption_key']); + 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']++; + continue; + } + if (!$subKey->canEncrypt()) { + $sortedKeys['noEncrypt']++; + continue; + } + $sortedKeys['valid']++; + } + if (!$sortedKeys['valid']) { + $result[2] = 'The user\'s PGP key does not include a valid subkey that could be used for encryption.'; + if ($sortedKeys['expired']) { + $result[2] .= ' ' . __n('Found %s subkey that have expired.', 'Found %s subkeys that have expired.', $sortedKeys['expired'], $sortedKeys['expired']); + } + if ($sortedKeys['noEncrypt']) { + $result[2] .= ' ' . __n('Found %s subkey that is sign only.', 'Found %s subkeys that are sign only.', $sortedKeys['noEncrypt'], $sortedKeys['noEncrypt']); + } + } else { + $result[0] = true; + } + } catch (\Exception $e) { + $result[2] = $e->getMessage(); + } + return $result; + } + + + /** + * Initialize GPG. Returns `null` if initialization failed. + * + * @return null|CryptGpgExtended + */ + public function initializeGpg() + { + require_once(ROOT . '/src/Lib/Tools/GpgTool.php'); + if ($this->gpg !== null) { + if ($this->gpg === false) { // initialization failed + return null; + } + return $this->gpg; + } + + try { + $this->gpg = \App\Lib\Tools\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); + $this->gpg = false; + return null; + } + } } diff --git a/templates/EncryptionKeys/index.php b/templates/EncryptionKeys/index.php index 413e2d8..1d9e241 100644 --- a/templates/EncryptionKeys/index.php +++ b/templates/EncryptionKeys/index.php @@ -48,6 +48,14 @@ echo $this->element('genericElements/IndexTable/index_table', [ 'owner_model_path' => 'owner_model', 'element' => 'owner' ], + [ + 'name' => __('Revoked'), + 'data_path' => 'fingerprint' + ], + [ + 'name' => __('Status'), + 'data_path' => 'status' + ], [ 'name' => __('Revoked'), 'sort' => 'revoked', @@ -56,7 +64,8 @@ echo $this->element('genericElements/IndexTable/index_table', [ ], [ 'name' => __('Key'), - 'data_path' => 'encryption_key' + 'data_path' => 'encryption_key', + 'element' => 'pgp_key' ], ], 'title' => __('Encryption key Index'), diff --git a/templates/EncryptionKeys/view.php b/templates/EncryptionKeys/view.php index e92da92..da95f99 100644 --- a/templates/EncryptionKeys/view.php +++ b/templates/EncryptionKeys/view.php @@ -1,32 +1,53 @@ element( - '/genericElements/SingleViews/single_view', - [ - 'data' => $entity, - 'fields' => [ - [ - 'key' => __('ID'), - 'path' => 'id' - ], - [ - 'key' => __('Type'), - 'path' => 'type' - ], - [ - 'key' => __('Owner'), - 'path' => 'owner_id', - 'owner_model_path' => 'owner_model', - 'type' => 'owner' - ], - [ - 'key' => __('Revoked'), - 'path' => 'revoked' - ], - - [ - 'key' => __('Key'), - 'path' => 'encryption_key' - ] + $fields = [ + [ + 'key' => __('ID'), + 'path' => 'id' + ], + [ + 'key' => __('Type'), + 'path' => 'type' + ], + [ + 'key' => __('Owner'), + 'path' => 'owner_id', + 'owner_model_path' => 'owner_model', + 'type' => 'owner' + ], + [ + 'key' => __('Revoked'), + 'path' => 'revoked', + 'type' => 'boolean' + ], + [ + 'key' => __('Key'), + 'path' => 'encryption_key', + 'type' => 'key' ] - ] -); + ]; + if ($entity['type'] === 'pgp') { + if (!empty($entity['pgp_fingerprint'])) { + $fields[] = [ + 'key' => __('Fingerprint'), + 'path' => 'pgp_fingerprint' + ]; + } + if (!empty($entity['pgp_error'])) { + $fields[] = [ + 'key' => __('PGP Status'), + 'path' => 'pgp_error' + ]; + } else { + $fields[] = [ + 'key' => __('PGP Status'), + 'raw' => __('OK') + ]; + } + } + echo $this->element( + '/genericElements/SingleViews/single_view', + [ + 'data' => $entity, + 'fields' => $fields + ] + ); diff --git a/templates/element/genericElements/IndexTable/Fields/pgp_key.php b/templates/element/genericElements/IndexTable/Fields/pgp_key.php new file mode 100644 index 0000000..fb29755 --- /dev/null +++ b/templates/element/genericElements/IndexTable/Fields/pgp_key.php @@ -0,0 +1,5 @@ +element('/genericElements/key', ['value' => $value, 'description' => $description ?? null]); +?> diff --git a/templates/element/genericElements/SingleViews/Fields/keyField.php b/templates/element/genericElements/SingleViews/Fields/keyField.php new file mode 100644 index 0000000..4a76e09 --- /dev/null +++ b/templates/element/genericElements/SingleViews/Fields/keyField.php @@ -0,0 +1,5 @@ +element('/genericElements/key', ['value' => $value, 'description' => $description ?? null]); +?> diff --git a/templates/element/genericElements/key.php b/templates/element/genericElements/key.php new file mode 100644 index 0000000..572b8d3 --- /dev/null +++ b/templates/element/genericElements/key.php @@ -0,0 +1,21 @@ +%s', + __('N/A') + ); + } else { + echo sprintf( + '
%s%s
', + !empty($description) ? + sprintf( + '%s', + h($description) + ) : '', + sprintf( + '
%s
', + h($value) + ) + ); + } +?> \ No newline at end of file