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