From 1d3673f117fbd35c5773b7972984320520a8d13d Mon Sep 17 00:00:00 2001 From: the-djmaze <> Date: Mon, 24 Jan 2022 13:01:37 +0100 Subject: [PATCH] Improved and bugfix handling of GnuPG for #89 --- .../app/libraries/RainLoop/Actions/Pgp.php | 53 +- .../app/libraries/snappymail/pgp/gnupg.php | 168 +++++- .../app/libraries/snappymail/pgp/gpg.php | 546 ++++++++++-------- 3 files changed, 461 insertions(+), 306 deletions(-) diff --git a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Pgp.php b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Pgp.php index 4ffbc5679..2404f1ef3 100644 --- a/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Pgp.php +++ b/snappymail/v/0.0.0/app/libraries/RainLoop/Actions/Pgp.php @@ -25,10 +25,10 @@ trait Pgp */ if (80 < \strlen($homedir)) { // First try a symbolic link - $link = \sys_get_temp_dir() . '/snappymail'; - if (\is_dir($link) || \mkdir($link, 0700, true)) { + $tmpdir = \sys_get_temp_dir() . '/snappymail'; + if (\is_dir($tmpdir) || \mkdir($tmpdir, 0700, true)) { $link = $tmpdir . '/' . \md5($homedir); - if (\is_link($homedir) || \symlink($homedir, $link)) { + if (\is_link($link) || \symlink($homedir, $link)) { $homedir = $link; } } @@ -49,50 +49,9 @@ trait Pgp public function DoGnupgGetKeys() : array { $GPG = $this->GnuPG(); - if ($GPG) { - $keys = []; - /** - * PECL GnuPG can't list private - * - * gpg --list-secret-keys - * gpg --list-public-keys - */ - foreach ($GPG->keyInfo('') as $info) { - if (!$info['disabled'] && !$info['expired'] && !$info['revoked']) { - $info['can_verify'] = $info['can_sign']; - $info['can_sign'] = $info['can_decrypt'] = false; - foreach ($info['subkeys'] as $key) { - $hasKey = $GPG->hasPrivateKey($key['keygrip']); - $info['can_sign'] = $info['can_sign'] || ($info['can_verify'] && $hasKey); - $info['can_decrypt'] = $info['can_decrypt'] || ($info['can_encrypt'] && $hasKey); - } - foreach ($info['uids'] as $uid) { - $id = $uid['email']; - if (isset($keys[$id])) { - // Public Key tasks - $keys[$id]['can_verify'] = $keys[$id]['can_verify'] || $info['can_verify']; - $keys[$id]['can_encrypt'] = $keys[$id]['can_encrypt'] || $info['can_encrypt']; - // Private Key tasks - $keys[$id]['can_sign'] = $keys[$id]['can_sign'] || $info['can_sign']; - $keys[$id]['can_decrypt'] = $keys[$id]['can_decrypt'] || $info['can_decrypt']; - } else { - $keys[$id] = [ - 'name' => $uid['name'], - 'email' => $uid['email'], - // Public Key tasks - 'can_verify' => $info['can_sign'], - 'can_encrypt' => $info['can_encrypt'], - // Private Key tasks - 'can_sign' => $info['can_sign'], - 'can_decrypt' => $info['can_decrypt'] - ]; - } - } - } - } - return $this->DefaultResponse(__FUNCTION__, $keys); - } - return $this->FalseResponse(__FUNCTION__); + return $GPG + ? $this->DefaultResponse(__FUNCTION__, $GPG->keyInfo('')) + : $this->FalseResponse(__FUNCTION__); } public function DoGnupgImportKey() : array diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/pgp/gnupg.php b/snappymail/v/0.0.0/app/libraries/snappymail/pgp/gnupg.php index 11d9ba585..83d3fd425 100644 --- a/snappymail/v/0.0.0/app/libraries/snappymail/pgp/gnupg.php +++ b/snappymail/v/0.0.0/app/libraries/snappymail/pgp/gnupg.php @@ -8,6 +8,8 @@ class GnuPG $homedir, // Instance of gnupg pecl extension $GnuPG, + // Instance of \SnappyMail\PGP\GPG + $GPG, // Instance of PEAR Crypt_GPG $Crypt_GPG; @@ -38,6 +40,9 @@ class GnuPG ]); // Output is ASCII $self->GnuPG->setarmor(1); + } else if (\SnappyMail\PGP\GPG::isSupported()) { + $self = new self; + $self->GPG = new \SnappyMail\PGP\GPG($homedir); } else { /** * $binary = trim(`which gpg`) ?: trim(`which gpg2`); @@ -68,6 +73,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->adddecryptkey($fingerprint, $passphrase); } + if ($this->GPG) { + return $this->GPG->adddecryptkey($fingerprint, $passphrase); + } if ($this->Crypt_GPG) { $this->Crypt_GPG->addDecryptKey($fingerprint, $passphrase); return true; @@ -83,6 +91,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->addencryptkey($fingerprint); } + if ($this->GPG) { + return $this->GPG->addencryptkey($fingerprint); + } if ($this->Crypt_GPG) { $this->Crypt_GPG->addEncryptKey($fingerprint); return true; @@ -98,6 +109,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->addsignkey($fingerprint, $passphrase); } + if ($this->GPG) { + return $this->GPG->addsignkey($fingerprint, $passphrase); + } if ($this->Crypt_GPG) { $this->Crypt_GPG->addSignKey($fingerprint, $passphrase); return true; @@ -113,6 +127,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->cleardecryptkeys(); } + if ($this->GPG) { + return $this->GPG->cleardecryptkeys(); + } if ($this->Crypt_GPG) { $this->Crypt_GPG->clearDecryptKeys(); return true; @@ -128,6 +145,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->clearencryptkeys(); } + if ($this->GPG) { + return $this->GPG->clearencryptkeys(); + } if ($this->Crypt_GPG) { $this->Crypt_GPG->clearEncryptKeys(); return true; @@ -143,6 +163,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->clearsignkeys(); } + if ($this->GPG) { + return $this->GPG->clearsignkeys(); + } if ($this->Crypt_GPG) { $this->Crypt_GPG->clearSignKeys(); return true; @@ -158,6 +181,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->decrypt($text); } + if ($this->GPG) { + return $this->GPG->decrypt($text); + } if ($this->Crypt_GPG) { return $this->Crypt_GPG->decrypt($encryptedData); } @@ -172,6 +198,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->decrypt(\file_get_contents($filename)); } + if ($this->GPG) { + return $this->GPG->decryptFile($filename); + } if ($this->Crypt_GPG) { return $this->Crypt_GPG->decryptFile($filename, $decryptedFile = null); } @@ -186,6 +215,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->decryptverify($text, $plaintext); } + if ($this->GPG) { + return $this->GPG->decryptverify($text, $plaintext); + } if ($this->Crypt_GPG) { return $this->Crypt_GPG->decryptAndVerify($text, $ignoreVerifyErrors = false); } @@ -200,6 +232,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->decryptverify(\file_get_contents($filename), $plaintext); } + if ($this->GPG) { + return $this->GPG->decryptverifyFile($filename, $plaintext); + } if ($this->Crypt_GPG) { return $this->Crypt_GPG->decryptAndVerifyFile($filename, $decryptedFile = null, $ignoreVerifyErrors = false); } @@ -214,6 +249,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->encrypt($plaintext); } + if ($this->GPG) { + return $this->GPG->encrypt($plaintext); + } if ($this->Crypt_GPG) { return $this->Crypt_GPG->encrypt($plaintext); } @@ -228,6 +266,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->encrypt(\file_get_contents($filename)); } + if ($this->GPG) { + return $this->GPG->encryptFile($filename); + } if ($this->Crypt_GPG) { return $this->Crypt_GPG->encryptFile($filename, $encryptedFile = null); } @@ -242,6 +283,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->encryptsign($plaintext); } + if ($this->GPG) { + return $this->GPG->encryptsign($plaintext); + } if ($this->Crypt_GPG) { return $this->Crypt_GPG->encryptAndSign($plaintext); } @@ -256,6 +300,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->encryptsign(\file_get_contents($filename)); } + if ($this->GPG) { + return $this->GPG->encryptsignFile($filename); + } if ($this->Crypt_GPG) { return $this->Crypt_GPG->encryptAndSignFile($filename, $signedFile = null); } @@ -270,6 +317,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->export($fingerprint); } + if ($this->GPG) { + return $this->GPG->export($fingerprint); + } if ($this->Crypt_GPG) { $this->Crypt_GPG->exportPrivateKey($fingerprint, $armor = true); $this->Crypt_GPG->exportPublicKey($fingerprint, $armor = true); @@ -286,6 +336,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->getengineinfo(); } + if ($this->GPG) { + return $this->GPG->getengineinfo(); + } if ($this->Crypt_GPG) { return [ 'protocol' => null, @@ -305,6 +358,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->geterror(); } + if ($this->GPG) { + return $this->GPG->geterror(); + } if ($this->Crypt_GPG) { return true; } @@ -319,6 +375,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->geterrorinfo(); } + if ($this->GPG) { + return $this->GPG->geterrorinfo(); + } if ($this->Crypt_GPG) { return true; } @@ -333,6 +392,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->getprotocol(); } + if ($this->GPG) { + return $this->GPG->getprotocol(); + } if ($this->Crypt_GPG) { return true; } @@ -347,6 +409,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->import($keydata); } + if ($this->GPG) { + return $this->GPG->import($keydata); + } if ($this->Crypt_GPG) { return $this->Crypt_GPG->importKey($keydata); } @@ -361,6 +426,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->import(\file_get_contents($filename)); } + if ($this->GPG) { + return $this->GPG->importFile($filename); + } if ($this->Crypt_GPG) { return $this->Crypt_GPG->importKeyFile($filename); } @@ -372,32 +440,65 @@ class GnuPG */ public function keyInfo(string $pattern) : array { - if ($this->GnuPG) { - return $this->GnuPG->keyinfo($pattern); -/* // v1.5 Slow and fails - return \array_merge( - // Public - $this->GnuPG->keyinfo($pattern), - // Private, read https://github.com/php-gnupg/php-gnupg/issues/5 - $this->GnuPG->keyinfo($pattern, 1) - ); + $keys = []; + $GPG = $this->GnuPG ?: $this->GPG; + if ($GPG) { + // Public + foreach ($GPG->keyinfo($pattern) as $info) { + if (!$info['disabled'] && !$info['expired'] && !$info['revoked']) { +/* + $hasPrivateKey = false; + foreach ($info['subkeys'] as $key) { + $hasPrivateKey |= \is_file("{$this->homedir}/private-keys-v1.d/{$key['keygrip']}.key"); + } */ + foreach ($info['uids'] as $uid) { + $id = $uid['email']; + if (isset($keys[$id])) { + $keys[$id]['can_sign'] = $keys[$id]['can_sign'] || $info['can_sign']; + $keys[$id]['can_encrypt'] = $keys[$id]['can_encrypt'] || $info['can_encrypt']; + } else { + $keys[$id] = [ + 'name' => $uid['name'], + 'email' => $uid['email'], + // Public Key tasks + 'can_verify' => $info['can_sign'], + 'can_encrypt' => $info['can_encrypt'], + // Private Key tasks + 'can_sign' => false, + 'can_decrypt' => false + ]; + } + } + } + } + // Private, read https://github.com/php-gnupg/php-gnupg/issues/5 + foreach ($GPG->keyinfo($pattern, 1) as $info) { + if (!$info['disabled'] && !$info['expired'] && !$info['revoked']) { + foreach ($info['uids'] as $uid) { + $id = $uid['email']; + if (isset($keys[$id])) { + $keys[$id]['can_sign'] = $keys[$id]['can_sign'] || $info['can_sign']; + $keys[$id]['can_decrypt'] = $keys[$id]['can_decrypt'] || $info['can_encrypt']; + } else { + $keys[$id] = [ + 'name' => $uid['name'], + 'email' => $uid['email'], + // Public Key tasks + 'can_verify' => false, + 'can_encrypt' => false, + // Private Key tasks + 'can_sign' => $info['can_sign'], + 'can_decrypt' => $info['can_encrypt'] + ]; + } + } + } + } } - if ($this->Crypt_GPG) { - return true; + else if ($this->Crypt_GPG) { } - return false; - } - - /** - * Returns an array with information about all keys that matches the given pattern - */ - public function hasPrivateKey(string $keygrip) : bool - { - if ($this->GnuPG || $this->Crypt_GPG) { - return \is_file("{$this->homedir}/private-keys-v1.d/{$keygrip}.key"); - } - return false; + return $keys; } /** @@ -409,6 +510,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->setarmor($armor ? 1 : 0); } + if ($this->GPG) { + return $this->GPG->setarmor($armor ? 1 : 0); + } if ($this->Crypt_GPG) { //$armor ? \Crypt_GPG::ARMOR_ASCII : \Crypt_GPG::ARMOR_ return true; @@ -426,6 +530,9 @@ class GnuPG if ($this->GnuPG) { $this->GnuPG->seterrormode($errormode); } + if ($this->GPG) { + $this->GPG->seterrormode($errormode); + } if ($this->Crypt_GPG) { } } @@ -440,6 +547,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->setsignmode($signmode); } + if ($this->GPG) { + return $this->GPG->setsignmode($signmode); + } if ($this->Crypt_GPG) { return true; } @@ -454,6 +564,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->sign($plaintext); } + if ($this->GPG) { + return $this->GPG->sign($plaintext); + } if ($this->Crypt_GPG) { return $this->Crypt_GPG->sign($data, $mode = self::SIGN_MODE_NORMAL); } @@ -468,6 +581,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->sign(\file_get_contents($filename)); } + if ($this->GPG) { + return $this->GPG->signFile($filename); + } if ($this->Crypt_GPG) { return $this->Crypt_GPG->signFile($filename, $signedFile = null, $mode = self::SIGN_MODE_NORMAL); } @@ -482,6 +598,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->verify($signed_text, $signature, $plaintext); } + if ($this->GPG) { + return $this->GPG->verify($signed_text, $signature, $plaintext); + } if ($this->Crypt_GPG) { return $this->Crypt_GPG->verify($signed_text, $signature = ''); } @@ -496,6 +615,9 @@ class GnuPG if ($this->GnuPG) { return $this->GnuPG->verify(\file_get_contents($filename), $signature, $plaintext); } + if ($this->GPG) { + return $this->GPG->verifyFile($filename, $signature, $plaintext); + } if ($this->Crypt_GPG) { return $this->Crypt_GPG->verifyFile($filename, $signature = ''); } diff --git a/snappymail/v/0.0.0/app/libraries/snappymail/pgp/gpg.php b/snappymail/v/0.0.0/app/libraries/snappymail/pgp/gpg.php index 8ee3f8040..a360d316a 100644 --- a/snappymail/v/0.0.0/app/libraries/snappymail/pgp/gpg.php +++ b/snappymail/v/0.0.0/app/libraries/snappymail/pgp/gpg.php @@ -1,19 +1,8 @@ $this->options['homedir']); + $env = array('GNUPGHOME' => $this->options['homedir']); $pipes = array(); if ($process = \proc_open($gpgconf . ' --kill gpg-agent --homedir ' . \escapeshellarg($this->options['homedir']), array(), $pipes, null, $env)) { \proc_close($process); @@ -149,7 +139,8 @@ class GPG */ public function addDecryptKey(string $fingerprint, string $passphrase) : bool { - return false; + $this->signKeys[$fingerprint] = $passphrase; + return true; } /** @@ -157,7 +148,8 @@ class GPG */ public function addEncryptKey(string $fingerprint) : bool { - return false; + $this->encryptKeys[$fingerprint] = 1; + return true; } /** @@ -165,6 +157,7 @@ class GPG */ public function addSignKey(string $fingerprint, ?string $passphrase) : bool { + $this->decryptKeys[$fingerprint] = $passphrase; return false; } @@ -173,7 +166,8 @@ class GPG */ public function clearDecryptKeys() : bool { - return false; + $this->decryptKeys = []; + return true; } /** @@ -181,7 +175,8 @@ class GPG */ public function clearEncryptKeys() : bool { - return false; + $this->encryptKeys = []; + return true; } /** @@ -189,7 +184,8 @@ class GPG */ public function clearSignKeys() : bool { - return false; + $this->signKeys = []; + return true; } /** @@ -301,12 +297,59 @@ class GPG return false; } + public function addPassphrase($key, $passphrase) + { + $this->passphrases[$key] = $passphrase; + return $this; + } + + protected function _importKey($input) /*: array|false*/ + { + $arguments = array('--import'); + + $envKeys = array(); + if (empty($this->passphrases)) { + $arguments[] = '--batch'; + } else { + foreach ($this->passphrases as $keyId => $key) { + $envKeys[$keyId] = \is_array($key) ? $key['passphrase'] : $key; + } + } + $_ENV['PINENTRY_USER_DATA'] = \json_encode($envKeys); + + $this->reset(); + $this->setInput($input); + $result = $this->exec($arguments); + + foreach ($result['status'] as $line) { + if (\strpos($line, 'IMPORT_RES')) { + $line = \explode(' ', \explode('IMPORT_RES ', $line)[1]); + return [ + 'imported' => (int) $line[2], + 'unchanged' => (int) $line[4], + 'newuserids' => (int) $line[5], + 'newsubkeys' => (int) $line[6], + 'secretimported' => (int) $line[10], + 'secretunchanged' => (int) $line[11], + 'newsignatures' => (int) $line[7], + 'skippedkeys' => (int) $line[12], + 'fingerprint' => '' + ]; + } + } + + return false; + } + /** * Imports a key */ public function import(string $keydata) /*: array|false*/ { - return false; + if (!$keydata) { + throw new \Exception('No valid input data found.'); + } + return $this->_importKey($keydata); } /** @@ -314,142 +357,49 @@ class GPG */ public function importFile(string $filename) /*: array|false*/ { - return false; - } - - /** - * Returns an array with information about all keys that matches the given pattern - */ - public function keyInfo(string $pattern) : array - { - return false; - } - - /** - * Returns an array with information about all keys that matches the given pattern - */ - public function hasPrivateKey(string $keygrip) : bool - { - return \is_file("{$this->options['homedir']}/private-keys-v1.d/{$keygrip}.key"); - } - - /** - * Toggle armored output - * When true the output is ASCII - */ - public function setArmor(bool $armor = true) : bool - { - return false; - } - - /** - * Sets the mode for error_reporting - * GNUPG_ERROR_WARNING, GNUPG_ERROR_EXCEPTION and GNUPG_ERROR_SILENT. - * By default GNUPG_ERROR_SILENT is used. - */ - public function setErrorMode(int $errormode) : void - { - } - - /** - * Sets the mode for signing - * GNUPG_SIG_MODE_NORMAL, GNUPG_SIG_MODE_DETACH and GNUPG_SIG_MODE_CLEAR. - * By default GNUPG_SIG_MODE_CLEAR - */ - public function setSignMode(int $signmode) : bool - { - return false; - } - - /** - * Signs a given text - */ - public function sign(string $plaintext) /*: string|false*/ - { - return false; - } - - /** - * Signs a given file - */ - public function signFile(string $filename) /*: string|false*/ - { - return false; - } - - /** - * Verifies a signed text - */ - public function verify(string $signed_text, string $signature, string &$plaintext = null) /*: array|false*/ - { - return false; - } - - /** - * Verifies a signed file - */ - public function verifyFile(string $filename, string $signature, string &$plaintext = null) /*: array|false*/ - { - return false; - } - - /** - * RFC 4880 - * https://datatracker.ietf.org/doc/html/rfc4880#section-5.2.3.5 - */ - public function signatureIssuer(string $signature) /*: array|false*/ - { - if (preg_match('/-----BEGIN PGP SIGNATURE-----(.+)-----END PGP SIGNATURE-----/', $signature, $match)) { - // TODO: use https://github.com/singpolyma/openpgp-php ? - $binary = \base64_decode(\trim($match[1])); - return \strtoupper(\bin2hex(\substr($binary, 24, 8))); - } - return false; - } - - /** - * This hangs with: - * gpg: can't connect to the agent: IPC connect call failed - */ - public function getPrivateKeys($keyId = '') : array - { - // According to The file 'doc/DETAILS' in the GnuPG distribution, using - // double '--with-fingerprint' also prints the fingerprint for subkeys. - $arguments = array( - '--with-colons', - '--with-fingerprint', - '--with-fingerprint', - '--fixed-list-mode', - '--list-secret-keys' - ); - if ($keyId) { - $arguments[] = '--utf8-strings'; - $arguments[] = \escapeshellarg($keyId); - } - - $output = ''; - - $this->reset(); - $this->setOutput($output); - $this->exec($arguments); - -echo $output; -return []; - - $fingerprints = array(); - foreach (\explode(PHP_EOL, $output) as $line) { - $lineExp = \explode(':', $line); - if ('fpr' === $lineExp[0]) { - $fingerprints[] = $lineExp[9]; + $fp = \fopen($filename, 'rb'); + try { + if (!$fp) { + throw new \Exception("Could not open file '{$filename}'"); } + return $this->_importKey($fp); + } finally { + $fp && \fclose($fp); } - return $fingerprints; } - public function getPublicKeys($keyId = '') : array + public function deleteKey(string $keyId, bool $private) { -// $privateKeyFingerprints = $this->getPrivateKeys($keyId); + $key = $this->keyInfo($keyId, $private ? 1 : 0); + if (!$key) { + return false; +// throw new \Exception(($private ? 'Private' : 'Public') . ' key not found: ' . $keyId); + } + if (!$private && $this->keyInfo($keyId, 1)) { + return false; + throw new \Exception('Delete private key first: ' . $keyId); + } + $this->reset(); + $result = $this->exec(array( + '--batch', + '--yes', + $private ? '--delete-secret-key' : '--delete-key', + \escapeshellarg($key[0]['subkeys'][0]['fingerprint']) + )); + +// $result['status'][0] = '[GNUPG:] ERROR keylist.getkey 17' +// $result['errors'][0] = 'gpg: error reading key: No public key' + +// print_r($result); + return true; + } + + /** + * Returns an array with information about all keys that matches the given pattern + */ + public function keyInfo(string $pattern, int $private = 0) : array + { // According to The file 'doc/DETAILS' in the GnuPG distribution, using // double '--with-fingerprint' also prints the fingerprint for subkeys. $arguments = array( @@ -457,24 +407,21 @@ return []; '--with-fingerprint', '--with-fingerprint', '--fixed-list-mode', - '--list-public-keys' // --list-keys ? + $private ? '--list-secret-keys' : '--list-public-keys' ); - if ($keyId) { + if ($pattern) { $arguments[] = '--utf8-strings'; - $arguments[] = \escapeshellarg($keyId); + $arguments[] = \escapeshellarg($pattern); } - $output = ''; - $this->reset(); - $this->setOutput($output); - $this->exec($arguments); + $result = $this->exec($arguments); $keys = array(); $key = null; // current key $subKey = null; // current sub-key - foreach (\explode(PHP_EOL, $output) as $line) { + foreach (\explode(PHP_EOL, $result['output']) as $line) { $tokens = \explode(':', $line); switch ($tokens[0]) @@ -482,6 +429,7 @@ return []; case 'tru': break; + case 'sec': case 'pub': // new primary key means last key should be added to the array if ($key !== null) { @@ -491,20 +439,21 @@ return []; 'disabled' => false, 'expired' => false, 'revoked' => false, - 'is_secret' => false, + 'is_secret' => 'ssb' === $tokens[0], 'can_sign' => \str_contains($tokens[11], 's'), 'can_encrypt' => \str_contains($tokens[11], 'e'), 'uids' => [], 'subkeys' => [] ]; - // Fall through to add (sub)key - case 'sub': + // Fall through to add subkey + case 'ssb': // secure subkey + case 'sub': // public subkey $key['subkeys'][] = [ 'fingerprint' => '', // fpr:::::::::....: 'keyid' => $tokens[4], 'timestamp' => $tokens[5], 'expires' => $tokens[6], - 'is_secret' => false, + 'is_secret' => 'ssb' === $tokens[0], 'invalid' => false, // escaESCA 'can_encrypt' => \str_contains($tokens[11], 'e'), @@ -523,6 +472,10 @@ return []; $key['subkeys'][\array_key_last($key['subkeys'])]['fingerprint'] = $tokens[9]; break; + case 'grp': + $key['subkeys'][\array_key_last($key['subkeys'])]['keygrip'] = $tokens[9]; + break; + case 'uid': $string = \stripcslashes($tokens[9]); // as per documentation $name = ''; @@ -570,45 +523,186 @@ return []; return $keys; } - private function _debug(string $msg) + /** + * Toggle armored output + * When true the output is ASCII + */ + public function setArmor(bool $armor = true) : bool + { + return false; + } + + /** + * Sets the mode for error_reporting + * GNUPG_ERROR_WARNING, GNUPG_ERROR_EXCEPTION and GNUPG_ERROR_SILENT. + * By default GNUPG_ERROR_SILENT is used. + */ + public function setErrorMode(int $errormode) : void + { + } + + /** + * Sets the mode for signing + * GNUPG_SIG_MODE_NORMAL, GNUPG_SIG_MODE_DETACH and GNUPG_SIG_MODE_CLEAR. + * By default GNUPG_SIG_MODE_CLEAR + */ + public function setSignMode(int $signmode) : bool + { + return false; + } + + /** + * Signs a given text + */ + public function sign(string $plaintext) /*: string|false*/ + { + return false; + } + + /** + * Signs a given file + */ + public function signFile(string $filename) /*: string|false*/ + { + return false; + } + + protected function _verify($input, string $signature) + { + $this->reset(); + + $arguments = array('--verify'); + if ('' === $signature) { + // signed or clearsigned data + $this->setInput($input); + } else { + // detached signature + $this->setInput($signature); + $this->_message =& $input; + // Signed data goes in FD_MESSAGE, detached signature data goes in FD_INPUT. + $arguments[] = '--enable-special-filenames'; + $arguments[] = '- "-&' . self::FD_MESSAGE . '"'; + } + + $result = $this->exec($arguments); + + $signatures = []; + foreach ($result['status'] as $line) { + $tokens = \explode(' ', $line); + switch ($tokens[0]) + { + case 'VERIFICATION_COMPLIANCE_MODE': + case 'TRUST_FULLY': + break; + + case 'EXPSIG': + case 'EXPKEYSIG': + case 'REVKEYSIG': + case 'BADSIG': + case 'ERRSIG': + case 'GOODSIG': + $signatures[] = [ + 'fingerprint' => '', + 'validity' => 0, + 'timestamp' => 0, + 'status' => 'GOODSIG' === $tokens[0] ? 0 : 1, + 'summary' => 'GOODSIG' === $tokens[0] ? 0 : 4, + 'keyid' => $tokens[1], + 'uid' => \rawurldecode(\implode(' ', \array_splice($tokens, 2))), + 'valid' => false + ]; + break; + + case 'VALIDSIG': + $last = \array_key_last($signatures); + $signatures[$last]['fingerprint'] = $tokens[1]; + $signatures[$last]['timestamp'] = (int) $tokens[3]; + $signatures[$last]['expires'] = (int) $tokens[4]; + $signatures[$last]['version'] = (int) $tokens[5]; +// $signatures[$last]['reserved'] = (int) $tokens[6]; +// $signatures[$last]['pubkey-algo'] = (int) $tokens[7]; +// $signatures[$last]['hash-algo'] = (int) $tokens[8]; +// $signatures[$last]['sig-class'] = $tokens[9]; +// $signatures[$last]['primary-fingerprint'] = $tokens[10]; + $signatures[$last]['valid'] = 0; + } + } + + return $signatures; + } + + /** + * Verifies a signed text + */ + public function verify(string $signed_text, string $signature, string &$plaintext = null) /*: array|false*/ + { + return $this->_verify($signed_text, $signature); + } + + /** + * Verifies a signed file + */ + public function verifyFile(string $filename, string $signature, string &$plaintext = null) /*: array|false*/ + { + $fp = \fopen($filename, 'rb'); + try { + if (!$fp) { + throw new \Exception("Could not open file '{$filename}'"); + } + return $this->_verify($fp, $signature); + } finally { + $fp && \fclose($fp); + } + } + + /** + * Verifies a signed file + */ + public function verifyStream($fp, string $signature, string &$plaintext = null) /*: array|false*/ + { + if (!$fp || !\is_resource($fp)) { + throw new \Exception('Invalid stream resource'); + } + return $this->_verify($fp, $signature); + } + + /** + * RFC 4880 + * https://datatracker.ietf.org/doc/html/rfc4880#section-5.2.3.5 + */ + public function signatureIssuer(string $signature) /*: array|false*/ + { + if (preg_match('/-----BEGIN PGP SIGNATURE-----(.+)-----END PGP SIGNATURE-----/', $signature, $match)) { + // TODO: use https://github.com/singpolyma/openpgp-php ? + $binary = \base64_decode(\trim($match[1])); + return \strtoupper(\bin2hex(\substr($binary, 24, 8))); + } + return false; + } + + private function _debug(string $msg) : void { if ($this->debug) { echo $msg . "\n"; } } - public function setOutput(&$output) + private function setInput(&$input) : void { - $this->_output =& $output; + $this->_input =& $input; } - /** - * Resets the GPG engine, preparing it for a new operation - * - * @return void - * - * @see Crypt_GPG_Engine::run() - * @see Crypt_GPG_Engine::setOperation() - */ - public function reset() + private function setOutput($output) : void { - $this->_message = null; - $this->_input = null; - $this->_output = ''; - $this->_commandBuffer = ''; + $this->_output = \is_resource($output) ? $output : null; + } - $this->_errorHandlers = array(); - $this->_statusHandlers = array(); -/* - if ($this->debug) { - $this->addStatusHandler(array($this, '_handleDebugStatus')); - $this->addErrorHandler(array($this, '_handleDebugError')); - } - - $this->_processHandler = new Crypt_GPG_ProcessHandler($this); - $this->addStatusHandler(array($this->_processHandler, 'handleStatus')); - $this->addErrorHandler(array($this->_processHandler, 'handleError')); -*/ + private function reset() : void + { + $this->_message = null; + $this->_input = null; + $this->_output = null; + $this->_commandBuffer = ''; } public function agent() @@ -617,7 +711,7 @@ return []; // echo `gpg-agent --daemon --homedir $home 2>&1`; } - private function exec(array $arguments) + private function exec(array $arguments) /*: array|false*/ { if (\version_compare($this->version, '2.2.5', '<')) { // Too old (<2018) @@ -717,9 +811,8 @@ return []; $messageComplete = true; } - if (\is_string($this->_output)) { - $outputBuffer =& $this->_output; - } + $status = []; + $errors = []; // convenience variables $fdInput = $proc_pipes[self::FD_INPUT]; @@ -727,7 +820,7 @@ return []; $fdError = $proc_pipes[self::FD_ERROR]; $fdStatus = $proc_pipes[self::FD_STATUS]; $fdCommand = $proc_pipes[self::FD_COMMAND]; - $fdMessage = $this->_openPipes->get(self::FD_MESSAGE); + $fdMessage = $proc_pipes[self::FD_MESSAGE]; // select loop delay in milliseconds $delay = 0; @@ -774,7 +867,7 @@ return []; } // set up output streams - if ($outputBuffer != '' && \is_resource($this->_output)) { + if ('' != $outputBuffer && $this->_output) { $outputStreams[] = $this->_output; } @@ -920,17 +1013,8 @@ return []; $this->_debug('GPG error stream ready for reading'); $this->_debug('=> about to read ' . self::CHUNK_SIZE . ' bytes from GPG error'); foreach ($this->_openPipes->readPipeLines(self::FD_ERROR) as $line) { - if (!$this->_errorHandlers) { - $this->debug ? $this->_debug('ERROR ' . $line) : \trigger_error($line, E_USER_WARNING); - } - foreach ($this->_errorHandlers as $handler) { - \array_unshift($handler['args'], $line); - \call_user_func_array( - $handler['callback'], - $handler['args'] - ); - \array_shift($handler['args']); - } + $errors[] = $line; + $this->debug && $this->_debug("\t{$line}"); } } @@ -940,20 +1024,11 @@ return []; $this->_debug('=> about to read ' . self::CHUNK_SIZE . ' bytes from GPG status'); // pass lines to status handlers foreach ($this->_openPipes->readPipeLines(self::FD_STATUS) as $line) { - if (!$this->_statusHandlers) { - $this->debug ? $this->_debug($line) : \trigger_error($line); - } // only pass lines beginning with magic prefix if ('[GNUPG:] ' == \substr($line, 0, 9)) { $line = \substr($line, 9); - foreach ($this->_statusHandlers as $handler) { - \array_unshift($handler['args'], $line); - \call_user_func_array( - $handler['callback'], - $handler['args'] - ); - \array_shift($handler['args']); - } + $status[] = $line; + $this->debug && $this->_debug("\t{$line}"); } } } @@ -992,10 +1067,18 @@ return []; $this->_debug('END PROCESSING'); $this->proc_close(); + + return [ + 'output' => $outputBuffer, + 'status' => $status, + 'errors' => $errors + ]; } - private function proc_close() + private function proc_close() : int { + $exitCode = 0; + // clear PINs from environment if they were set $_ENV['PINENTRY_USER_DATA'] = null; @@ -1018,18 +1101,9 @@ return []; if ($exitCode > 0) { $this->_debug('=> subprocess returned an unexpected exit code: ' . $exitCode); } - - // close file handles before throwing an exception - if (\is_resource($this->_input)) { - \fclose($this->_input); - } - - if (\is_resource($this->_output)) { - \fclose($this->_output); - } - - $this->_processHandler && $this->_processHandler->throwException($exitCode); } + + return $exitCode; } private static function findBinary($name) : ?string @@ -1131,7 +1205,7 @@ class GpgProcPipes // the pipe was seleted for writing, we assume it was EPIPE. // There's no way to get the actual error code in PHP. See // PHP Bug #39598. https://bugs.php.net/bug.php?id=39598 - $this->pipes->close($number); + $this->close($number); } return $length ?: 0; }