mirror of
https://github.com/the-djmaze/snappymail.git
synced 2026-09-04 23:17:02 +03:00
1497 lines
38 KiB
PHP
1497 lines
38 KiB
PHP
<?php
|
|
/**
|
|
* This class is inspired by PEAR Crypt_GPG and PECL gnupg
|
|
* It does not support gpg v1 because that is missing ECDH, ECDSA, EDDSA
|
|
* It does not support gpg < v2.2.5 as they are from before 2018
|
|
*/
|
|
|
|
namespace SnappyMail\PGP;
|
|
|
|
class GPG
|
|
{
|
|
const
|
|
// @see https://pear.php.net/bugs/bug.php?id=21077
|
|
CHUNK_SIZE = 65536,
|
|
|
|
// This is used to pass data to the GPG process.
|
|
FD_INPUT = 0,
|
|
|
|
// This is used to receive normal output from the GPG process.
|
|
FD_OUTPUT = 1,
|
|
|
|
// This is used to receive error output from the GPG process.
|
|
FD_ERROR = 2,
|
|
|
|
/**
|
|
* GPG status output file descriptor. The status file descriptor outputs
|
|
* detailed information for many GPG commands. See the second section of
|
|
* the file <b>doc/DETAILS</b> in the
|
|
* {@link http://www.gnupg.org/download/ GPG package} for a detailed
|
|
* description of GPG's status output.
|
|
*/
|
|
FD_STATUS = 3,
|
|
|
|
// This is used for methods requiring passphrases.
|
|
FD_COMMAND = 4,
|
|
|
|
// This is used for passing signed data when verifying a detached signature.
|
|
FD_MESSAGE = 5;
|
|
|
|
public
|
|
$strict = false;
|
|
|
|
private
|
|
$_message,
|
|
$_input,
|
|
$_output,
|
|
|
|
$signKeys = [],
|
|
$encryptKeys = [],
|
|
$decryptKeys = [];
|
|
|
|
private
|
|
$binary,
|
|
$version = '2.0',
|
|
$ciphers = [],
|
|
$digests = [],
|
|
$curves = [],
|
|
$pubkey_types = [],
|
|
$compressions = [],
|
|
$passphrases = [],
|
|
|
|
$proc_resource,
|
|
|
|
$armor = true,
|
|
|
|
$signmode = 2,
|
|
|
|
// https://www.gnupg.org/documentation/manuals/gnupg/GPG-Configuration-Options.html
|
|
$options = [
|
|
'homedir' => '',
|
|
'keyring' => '',
|
|
'secret-keyring' => '',
|
|
'digest-algo' => '',
|
|
'cipher-algo' => '',
|
|
/*
|
|
2 = ZLIB (GnuPG, default)
|
|
1 = ZIP (PGP)
|
|
0 = Uncompressed
|
|
*/
|
|
'compress-algo' => 2,
|
|
];
|
|
|
|
function __construct(string $homedir)
|
|
{
|
|
$this->options['homedir'] = \rtrim($homedir, '/\\');
|
|
|
|
// the random seed file makes subsequent actions faster so only disable it if we have to.
|
|
if ($this->options['homedir'] && !\is_writable($this->options['homedir'])) {
|
|
$this->options['no-random-seed-file'] = true;
|
|
}
|
|
|
|
// How to use gpgme-json ?
|
|
$this->binary = static::findBinary('gpg');
|
|
|
|
$info = \preg_replace('/\R +/', ' ', `$this->binary --with-colons --list-config`);
|
|
if (\preg_match('/cfg:version:([0-9]+\\.[0-9]+\\.[0-9]+)/', $info, $match)) {
|
|
$this->version = $match[1];
|
|
}
|
|
if (\preg_match('/cfg:cipher:(.+)/', $info, $match) && \preg_match('/cfg:ciphername:(.+)/', $info, $match1)) {
|
|
$this->ciphers = \array_combine(\explode(';', $match[1]), \explode(';', $match1[1]));
|
|
}
|
|
if (\preg_match('/cfg:digest:(.+)/', $info, $match) && \preg_match('/cfg:digestname:(.+)/', $info, $match1)) {
|
|
$this->digests = \array_combine(\explode(';', $match[1]), \explode(';', $match1[1]));
|
|
}
|
|
if (\preg_match('/cfg:pubkey:(.+)/', $info, $match) && \preg_match('/cfg:pubkeyname:(.+)/', $info, $match1)) {
|
|
$this->pubkey_types = \array_combine(\explode(';', $match[1]), \explode(';', $match1[1]));
|
|
}
|
|
if (\preg_match('/cfg:compress:(.+)/', $info, $match) && \preg_match('/cfg:compressname:(.+)/', $info, $match1)) {
|
|
$this->compressions = \array_combine(\explode(';', $match[1]), \explode(';', $match1[1]));
|
|
}
|
|
if (\preg_match('/cfg:curve:(.+)/', $info, $match)) {
|
|
$this->curves = \explode(';', $match[1]);
|
|
}
|
|
}
|
|
|
|
function __destruct()
|
|
{
|
|
$this->proc_close();
|
|
|
|
$gpgconf = static::findBinary('gpgconf');
|
|
if ($gpgconf) {
|
|
$env = ['GNUPGHOME' => $this->options['homedir']];
|
|
$pipes = [];
|
|
if ($process = \proc_open($gpgconf . ' --kill gpg-agent --homedir ' . \escapeshellarg($this->options['homedir']), [], $pipes, null, $env)) {
|
|
\proc_close($process);
|
|
}
|
|
}
|
|
}
|
|
|
|
public static function isSupported() : bool
|
|
{
|
|
return \is_callable('proc_open') && static::findBinary('gpg');
|
|
}
|
|
|
|
/**
|
|
* Add a key for decryption
|
|
*/
|
|
public function addDecryptKey(string $fingerprint, string $passphrase) : bool
|
|
{
|
|
$this->decryptKeys[$fingerprint] = $passphrase;
|
|
// $this->decryptKeys[\substr($fingerprint, -16)] = $passphrase;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Add a key for encryption
|
|
*/
|
|
public function addEncryptKey(string $fingerprint) : bool
|
|
{
|
|
$this->encryptKeys[$fingerprint] = 1;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Add a key for signing
|
|
*/
|
|
public function addSignKey(string $fingerprint, string $passphrase) : bool
|
|
{
|
|
$this->signKeys[$fingerprint] = $passphrase;
|
|
// $this->signKeys[\substr($fingerprint, -16)] = $passphrase;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Removes all keys which were set for decryption before
|
|
*/
|
|
public function clearDecryptKeys() : bool
|
|
{
|
|
$this->decryptKeys = [];
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Removes all keys which were set for encryption before
|
|
*/
|
|
public function clearEncryptKeys() : bool
|
|
{
|
|
$this->encryptKeys = [];
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Removes all keys which were set for signing before
|
|
*/
|
|
public function clearSignKeys() : bool
|
|
{
|
|
$this->signKeys = [];
|
|
return true;
|
|
}
|
|
|
|
protected function _decrypt(/*string|resource*/ $input, /*string|resource*/ $output = null)
|
|
{
|
|
$this->setInput($input);
|
|
|
|
$fclose = $this->setOutput($output);
|
|
|
|
if ($this->decryptKeys) {
|
|
$_ENV['PINENTRY_USER_DATA'] = \json_encode($this->decryptKeys);
|
|
}
|
|
|
|
$result = $this->exec(['--decrypt','--skip-verify']);
|
|
|
|
$fclose && \fclose($fclose);
|
|
|
|
return $output ? true : ($result ? $result['output'] : false);
|
|
}
|
|
|
|
/**
|
|
* Decrypts a given text
|
|
*/
|
|
public function decrypt(string $text) /*: string|false */
|
|
{
|
|
return $this->_decrypt($text);
|
|
}
|
|
|
|
/**
|
|
* Decrypts a given file
|
|
*/
|
|
public function decryptFile(string $filename) /*: string|false */
|
|
{
|
|
$fp = \fopen($filename, 'rb');
|
|
try {
|
|
if (!$fp) {
|
|
throw new \Exception("Could not open file '{$filename}'");
|
|
}
|
|
return $this->_decrypt($fp, $output);
|
|
} finally {
|
|
$fp && \fclose($fp);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decrypts a given stream
|
|
*/
|
|
public function decryptStream($fp, /*string|resource*/ $output = null) /*: string|false*/
|
|
{
|
|
if (!$fp || !\is_resource($fp)) {
|
|
throw new \Exception('Invalid stream resource');
|
|
}
|
|
return $this->_decrypt($fp, $output);
|
|
}
|
|
|
|
/**
|
|
* Decrypts and verifies a given text
|
|
*/
|
|
public function decryptVerify(string $text, string &$plaintext) /*: array|false*/
|
|
{
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Decrypts and verifies a given file
|
|
*/
|
|
public function decryptVerifyFile(string $filename, string &$plaintext) /*: array|false*/
|
|
{
|
|
return false;
|
|
}
|
|
|
|
protected function _encrypt(/*string|resource*/ $input, /*string|resource*/ $output = null)
|
|
{
|
|
if (!$this->encryptKeys) {
|
|
throw new \Exception('No encryption keys specified.');
|
|
}
|
|
|
|
$this->setInput($input);
|
|
|
|
$fclose = $this->setOutput($output);
|
|
|
|
$arguments = [
|
|
'--encrypt'
|
|
];
|
|
if ($this->armor) {
|
|
$arguments[] = '--armor';
|
|
}
|
|
|
|
foreach ($this->encryptKeys as $fingerprint => $dummy) {
|
|
$arguments[] = '--recipient ' . \escapeshellarg($fingerprint);
|
|
}
|
|
|
|
$result = $this->exec($arguments);
|
|
|
|
$fclose && \fclose($fclose);
|
|
|
|
return $output ? true : $result['output'];
|
|
}
|
|
|
|
/**
|
|
* Encrypts a given text
|
|
*/
|
|
public function encrypt(string $plaintext, /*string|resource*/ $output = null) /*: string|false*/
|
|
{
|
|
return $this->_encrypt($plaintext, $output);
|
|
}
|
|
|
|
/**
|
|
* Encrypts a given text
|
|
*/
|
|
public function encryptFile(string $filename, /*string|resource*/ $output = null) /*: string|false*/
|
|
{
|
|
$fp = \fopen($filename, 'rb');
|
|
try {
|
|
if (!$fp) {
|
|
throw new \Exception("Could not open file '{$filename}'");
|
|
}
|
|
return $this->_encrypt($filename, $output);
|
|
} finally {
|
|
$fp && \fclose($fp);
|
|
}
|
|
}
|
|
|
|
public function encryptStream(/*resource*/ $fp, /*string|resource*/ $output = null) /*: string|false*/
|
|
{
|
|
if (!$fp || !\is_resource($fp)) {
|
|
throw new \Exception('Invalid stream resource');
|
|
}
|
|
return $this->_encrypt($fp, $output);
|
|
}
|
|
|
|
/**
|
|
* Encrypts and signs a given text
|
|
*/
|
|
public function encryptSign(string $plaintext) /*: string|false*/
|
|
{
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Encrypts and signs a given text
|
|
*/
|
|
public function encryptSignFile(string $filename) /*: string|false*/
|
|
{
|
|
return false;
|
|
}
|
|
|
|
protected function _exportKey($keyId, $private = false)
|
|
{
|
|
$keys = $this->keyInfo($keyId, $private ? 1 : 0);
|
|
if (!$keys) {
|
|
throw new \Exception(($private ? 'Private' : 'Public') . ' key not found: ' . $keyId);
|
|
}
|
|
if ($private && $this->passphrases) {
|
|
$_ENV['PINENTRY_USER_DATA'] = \json_encode($this->passphrases);
|
|
}
|
|
$result = $this->exec([
|
|
$private ? '--export-secret-keys' : '--export',
|
|
'--armor',
|
|
\escapeshellarg($keys[0]['subkeys'][0]['fingerprint']),
|
|
]);
|
|
return $result['output'];
|
|
}
|
|
|
|
/**
|
|
* Exports a public key
|
|
*/
|
|
public function export(string $fingerprint) /*: string|false*/
|
|
{
|
|
return $this->_exportKey($fingerprint);
|
|
}
|
|
|
|
/**
|
|
* Exports a private key
|
|
*/
|
|
public function exportPrivateKey(string $fingerprint) /*: string|false*/
|
|
{
|
|
return $this->_exportKey($fingerprint, true);
|
|
}
|
|
|
|
/**
|
|
* Returns the engine info
|
|
*/
|
|
public function getEngineInfo() : array
|
|
{
|
|
return [
|
|
'protocol' => null,
|
|
'file_name' => $this->binary,
|
|
'home_dir' => $this->options['homedir'],
|
|
'version' => $this->version
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Returns the errortext, if a function fails
|
|
*/
|
|
public function getError() /*: string|false*/
|
|
{
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns the error info
|
|
*/
|
|
public function getErrorInfo() : array
|
|
{
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Returns the currently active protocol for all operations
|
|
*/
|
|
public function getProtocol() : int
|
|
{
|
|
return 0;
|
|
}
|
|
|
|
public function addPassphrase($keyId, $passphrase)
|
|
{
|
|
$this->passphrases[$keyId] = $passphrase;
|
|
return $this;
|
|
}
|
|
|
|
/**
|
|
* Generates a key
|
|
* Also saves revocation certificate in {homedir}/openpgp-revocs.d/
|
|
* https://www.gnupg.org/documentation/manuals/gnupg/OpenPGP-Key-Management.html
|
|
*/
|
|
public function generateKey(GPGKeySettings $settings) /*: string|false*/
|
|
{
|
|
$arguments = [
|
|
'--batch',
|
|
'--yes',
|
|
'--passphrase', \escapeshellarg($settings->passphrase)
|
|
];
|
|
|
|
/**
|
|
* https://www.gnupg.org/documentation/manuals/gnupg/Unattended-GPG-key-generation.html
|
|
* But it can't generate multiple subkeys
|
|
* Somehow generating first subkey is also broken in v2.3.4
|
|
$this->_input = $settings->asUnattendedData();
|
|
$result = $this->exec(['--batch', '--yes', '--full-gen-key']);
|
|
*/
|
|
$result = $this->exec(\array_merge($arguments, [
|
|
'--quick-gen-key',
|
|
\escapeshellarg($settings->uid()),
|
|
$settings->algo(),
|
|
$settings->usage
|
|
]));
|
|
|
|
$fingerprint = '';
|
|
foreach ($result['status'] as $line) {
|
|
$tokens = \explode(' ', $line);
|
|
if ('KEY_CREATED' === $tokens[0]/* && 'P' === $tokens[1]*/) {
|
|
$fingerprint = $tokens[2];
|
|
}
|
|
}
|
|
if (!$fingerprint) {
|
|
if (!empty($result['errors'])) {
|
|
\SnappyMail\Log::error('GPG', \implode("\n\t", $result['errors']));
|
|
}
|
|
return false;
|
|
}
|
|
|
|
$arguments[] = '--quick-add-key';
|
|
$arguments[] = $fingerprint;
|
|
|
|
foreach ($settings->subkeys as $i => $key) {
|
|
$algo = 'default';
|
|
if (!empty($key['curve'])) {
|
|
$algo = $key['curve'];
|
|
}
|
|
if (!empty($key['type'])) {
|
|
$algo = $key['type'] . ($key['length'] ?? '');
|
|
}
|
|
$this->exec(\array_merge($arguments, [$algo, $key['usage'], '0']));
|
|
}
|
|
|
|
/*
|
|
[status][0] => KEY_NOT_CREATED
|
|
[errors][0] => gpg: -:3: specified Key-Usage not allowed for algo 22
|
|
[errors][0] => gpg: key generation failed: Unknown elliptic curve
|
|
|
|
[status][0] => KEY_CONSIDERED B2FD2BCADCC6A9E4B2C90DBBE776CADFF94D327F 0
|
|
[status][1] => KEY_CREATED P B2FD2BCADCC6A9E4B2C90DBBE776CADFF94D327F
|
|
*/
|
|
return $fingerprint;
|
|
}
|
|
|
|
protected function _importKey($input) /*: array|false*/
|
|
{
|
|
$arguments = ['--import'];
|
|
|
|
if ($this->passphrases) {
|
|
$_ENV['PINENTRY_USER_DATA'] = \json_encode($this->passphrases);
|
|
} else {
|
|
$arguments[] = '--batch';
|
|
}
|
|
|
|
$this->setInput($input);
|
|
$result = $this->exec($arguments);
|
|
|
|
foreach ($result['status'] as $line) {
|
|
if (false !== \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' => ''
|
|
];
|
|
}
|
|
}
|
|
|
|
if (!empty($result['errors'][0])) {
|
|
\SnappyMail\Log::warning('GPG', $result['errors'][0]);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Imports a key
|
|
*/
|
|
public function import(string $keydata) /*: array|false*/
|
|
{
|
|
if (!$keydata) {
|
|
throw new \Exception('No valid input data found.');
|
|
}
|
|
return $this->_importKey($keydata);
|
|
}
|
|
|
|
/**
|
|
* Imports a key
|
|
*/
|
|
public function importFile(string $filename) /*: array|false*/
|
|
{
|
|
$fp = \fopen($filename, 'rb');
|
|
try {
|
|
if (!$fp) {
|
|
throw new \Exception("Could not open file '{$filename}'");
|
|
}
|
|
return $this->_importKey($fp);
|
|
} finally {
|
|
$fp && \fclose($fp);
|
|
}
|
|
}
|
|
|
|
public function deleteKey(string $keyId, bool $private)
|
|
{
|
|
$key = $this->keyInfo($keyId, $private ? 1 : 0);
|
|
if (!$key) {
|
|
throw new \Exception(($private ? 'Private' : 'Public') . ' key not found: ' . $keyId);
|
|
}
|
|
if (!$private && $this->keyInfo($keyId, 1)) {
|
|
throw new \Exception('Delete private key first: ' . $keyId);
|
|
}
|
|
|
|
$result = $this->exec([
|
|
'--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 = [
|
|
'--with-colons',
|
|
'--with-fingerprint',
|
|
'--with-fingerprint',
|
|
'--fixed-list-mode',
|
|
$private ? '--list-secret-keys' : '--list-public-keys'
|
|
];
|
|
if ($pattern) {
|
|
$arguments[] = '--utf8-strings';
|
|
$arguments[] = \escapeshellarg($pattern);
|
|
}
|
|
|
|
$result = $this->exec($arguments);
|
|
|
|
$keys = [];
|
|
$key = null; // current key
|
|
$subKey = null; // current sub-key
|
|
|
|
foreach (\explode(PHP_EOL, $result['output']) as $line) {
|
|
$tokens = \explode(':', $line);
|
|
|
|
switch ($tokens[0])
|
|
{
|
|
case 'tru':
|
|
break;
|
|
|
|
case 'sec':
|
|
case 'pub':
|
|
// new primary key means last key should be added to the array
|
|
if ($key !== null) {
|
|
$keys[] = $key;
|
|
}
|
|
$key = [
|
|
'disabled' => false,
|
|
'expired' => false,
|
|
'revoked' => 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 subkey
|
|
case 'ssb': // secure subkey
|
|
case 'sub': // public subkey
|
|
$key['subkeys'][] = [
|
|
'fingerprint' => '', // fpr:::::::::....:
|
|
'keyid' => $tokens[4],
|
|
'timestamp' => $tokens[5],
|
|
'expires' => $tokens[6],
|
|
'is_secret' => 'ssb' === $tokens[0],
|
|
'invalid' => false,
|
|
// escaESCA
|
|
'can_encrypt' => \str_contains($tokens[11], 'e'),
|
|
'can_sign' => \str_contains($tokens[11], 's'),
|
|
'can_certify' => \str_contains($tokens[11], 'c'),
|
|
'can_authenticate' => \str_contains($tokens[11], 'a'),
|
|
'disabled' => false,
|
|
'expired' => false,
|
|
'revoked' => 'r' === $tokens[1],
|
|
'length' => $tokens[2],
|
|
'algorithm' => $tokens[3],
|
|
];
|
|
break;
|
|
|
|
case 'fpr':
|
|
$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 = '';
|
|
$email = '';
|
|
$comment = '';
|
|
$matches = [];
|
|
|
|
// get email address from end of string if it exists
|
|
if (\preg_match('/^(.*?)<([^>]+)>$/', $string, $matches)) {
|
|
$string = \trim($matches[1]);
|
|
$email = $matches[2];
|
|
}
|
|
|
|
// get comment from end of string if it exists
|
|
$matches = [];
|
|
if (\preg_match('/^(.+?) \(([^\)]+)\)$/', $string, $matches)) {
|
|
$string = $matches[1];
|
|
$comment = $matches[2];
|
|
}
|
|
|
|
// there can be an email without a name
|
|
if (!$email && \preg_match('/^[\S]+@[\S]+$/', $string, $matches)) {
|
|
$email = $string;
|
|
} else {
|
|
$name = $string;
|
|
}
|
|
|
|
$key['uids'][] = [
|
|
'name' => $name,
|
|
'comment' => $comment,
|
|
'email' => $email,
|
|
'uid' => $tokens[9],
|
|
'revoked' => 'r' === $tokens[1],
|
|
'invalid' => false,
|
|
];
|
|
break;
|
|
}
|
|
}
|
|
|
|
// add last key
|
|
if ($key) {
|
|
$keys[] = $key;
|
|
}
|
|
|
|
return $keys;
|
|
}
|
|
|
|
/**
|
|
* Toggle the armored output
|
|
*/
|
|
public function setArmor(int $armor = 1) : bool
|
|
{
|
|
$this->armor = !!$armor;
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 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, GNUPG_SIG_MODE_CLEAR
|
|
* By default GNUPG_SIG_MODE_CLEAR
|
|
*/
|
|
public function setSignMode(int $signmode) : bool
|
|
{
|
|
$this->signmode = $signmode;
|
|
return true;
|
|
}
|
|
|
|
protected function _sign(/*string|resource*/ $input, /*string|resource*/ $output = null, bool $textmode = true) /*: string|false*/
|
|
{
|
|
if (empty($this->signKeys)) {
|
|
throw new \Exception('No signing keys specified.');
|
|
}
|
|
|
|
$this->setInput($input);
|
|
|
|
$fclose = $this->setOutput($output);
|
|
|
|
$arguments = [];
|
|
|
|
switch ($this->signmode)
|
|
{
|
|
case 0: // GNUPG_SIG_MODE_NORMAL
|
|
$arguments[] = '--sign';
|
|
break;
|
|
case 1: // GNUPG_SIG_MODE_DETACH
|
|
$arguments[] = '--detach-sign';
|
|
break;
|
|
case 2: // GNUPG_SIG_MODE_CLEAR
|
|
default:
|
|
$arguments[] = '--clearsign';
|
|
break;
|
|
}
|
|
|
|
if ($this->armor) {
|
|
$arguments[] = '--armor';
|
|
}
|
|
if ($textmode) {
|
|
$arguments[] = '--textmode';
|
|
}
|
|
|
|
if ($this->signKeys) {
|
|
foreach ($this->signKeys as $fingerprint => $pass) {
|
|
$arguments[] = '--local-user ' . \escapeshellarg($fingerprint);
|
|
}
|
|
$_ENV['PINENTRY_USER_DATA'] = \json_encode($this->signKeys);
|
|
}
|
|
|
|
$result = $this->exec($arguments);
|
|
|
|
$fclose && \fclose($fclose);
|
|
|
|
return $output ? true : $result['output'];
|
|
}
|
|
|
|
/**
|
|
* Signs a given text
|
|
*/
|
|
public function sign(string $plaintext, /*string|resource*/ $output = null) /*: string|false*/
|
|
{
|
|
return $this->_sign($plaintext, $output);
|
|
}
|
|
|
|
/**
|
|
* Signs a given file
|
|
*/
|
|
public function signFile(string $filename, /*string|resource*/ $output = null) /*: string|false*/
|
|
{
|
|
$fp = \fopen($filename, 'rb');
|
|
try {
|
|
if (!$fp) {
|
|
throw new \Exception("Could not open file '{$filename}'");
|
|
}
|
|
return $this->_sign($fp, $output);
|
|
} finally {
|
|
$fp && \fclose($fp);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Signs a given file
|
|
*/
|
|
public function signStream($fp, /*string|resource*/ $output = null) /*: array|false*/
|
|
{
|
|
if (!$fp || !\is_resource($fp)) {
|
|
throw new \Exception('Invalid stream resource');
|
|
}
|
|
return $this->_sign($fp, $output);
|
|
}
|
|
|
|
protected function _verify($input, string $signature)
|
|
{
|
|
$arguments = ['--verify'];
|
|
if ($signature) {
|
|
// 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 . '"';
|
|
} else {
|
|
// signed or clearsigned data
|
|
$this->setInput($input);
|
|
}
|
|
|
|
$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);
|
|
}
|
|
|
|
private function _debug(string $msg) : void
|
|
{
|
|
\SnappyMail\Log::debug('GPG', $msg);
|
|
}
|
|
|
|
private function setInput(&$input) : void
|
|
{
|
|
if (\is_resource($input)) {
|
|
// https://github.com/the-djmaze/snappymail/issues/331
|
|
// $meta['stream_type'] == MEMORY or $meta['wrapper_data'] == MailSo\Base\StreamWrappers\Literal
|
|
$meta = \stream_get_meta_data($input);
|
|
if ('STDIO' != $meta['stream_type']) {
|
|
$input = \stream_get_contents($input);
|
|
}
|
|
}
|
|
$this->_input =& $input;
|
|
}
|
|
|
|
private function setOutput($output)/* : resource|false*/
|
|
{
|
|
$fclose = false;
|
|
if ($output && !\is_resource($output)) {
|
|
$output = \fopen($output, 'wb');
|
|
if (!$output) {
|
|
throw new \Exception("Could not open file '{$filename}'");
|
|
}
|
|
$fclose = $output;
|
|
}
|
|
$this->_output = $output;
|
|
return $fclose;
|
|
}
|
|
|
|
public function agent()
|
|
{
|
|
// $home = \escapeshellarg($this->options['homedir']);
|
|
// echo `gpg-agent --daemon --homedir $home 2>&1`;
|
|
}
|
|
|
|
public function getEncryptedMessageKeys(/*string|resource*/ $data) : array
|
|
{
|
|
$this->setInput($data);
|
|
// $_ENV['PINENTRY_USER_DATA'] = null;
|
|
$result = $this->exec(['--decrypt','--skip-verify']);
|
|
$info = [
|
|
'ENC_TO' => [],
|
|
// 'KEY_CONSIDERED' => [],
|
|
// 'NO_SECKEY' => [],
|
|
// 'errors' => $result['errors']
|
|
];
|
|
foreach ($result['status'] as $line) {
|
|
$tokens = \explode(' ', $line);
|
|
if (isset($info[$tokens[0]])) {
|
|
$info[$tokens[0]][] = $tokens[1];
|
|
}
|
|
}
|
|
return $info['ENC_TO'];
|
|
}
|
|
|
|
private function exec(array $arguments) /*: array|false*/
|
|
{
|
|
if (\version_compare($this->version, '2.2.5', '<')) {
|
|
\SnappyMail\Log::error('GPG', "{$this->version} too old");
|
|
return false;
|
|
}
|
|
|
|
$defaultArguments = [
|
|
'--status-fd ' . self::FD_STATUS,
|
|
'--command-fd ' . self::FD_COMMAND,
|
|
// '--no-greeting',
|
|
'--no-secmem-warning',
|
|
'--no-tty',
|
|
'--no-default-keyring', // ignored if keying files are not specified
|
|
'--no-options', // prevent creation of ~/.gnupg directory
|
|
'--no-permission-warning', // 1.0.7+
|
|
// '--no-use-agent', // < 2.0.0
|
|
'--exit-on-status-write-error', // 1.4.2+
|
|
'--trust-model always', // 1.3.2+ else --always-trust
|
|
// If no passphrases are set, cancel them
|
|
'--pinentry-mode ' . (empty($_ENV['PINENTRY_USER_DATA']) ? 'cancel' : 'loopback') // 2.1.13+
|
|
];
|
|
|
|
if (!$this->strict) {
|
|
$defaultArguments[] = '--ignore-time-conflict';
|
|
$defaultArguments[] = '--ignore-valid-from';
|
|
}
|
|
|
|
if ($this->options['digest-algo']) {
|
|
$this->options['s2k-digest-algo'] = $this->options['digest-algo'];
|
|
}
|
|
if ($this->options['cipher-algo']) {
|
|
$this->options['s2k-cipher-algo'] = $this->options['cipher-algo'];
|
|
}
|
|
|
|
foreach ($this->options as $option => $value) {
|
|
if (\is_string($value)) {
|
|
if (\strlen($value)) {
|
|
$defaultArguments[] = "--{$option} " . \escapeshellarg($value);
|
|
}
|
|
} else if (true === $value) {
|
|
$defaultArguments[] = "--{$option}";
|
|
} else if ('compress-algo' === $option && 2 !== $value) {
|
|
$defaultArguments[] = "--{$option} " . \intval($value);
|
|
}
|
|
}
|
|
|
|
$commandLine = $this->binary . ' ' . \implode(' ', \array_merge($defaultArguments, $arguments));
|
|
|
|
$descriptorSpec = [
|
|
self::FD_INPUT => array('pipe', 'rb'), // stdin
|
|
self::FD_OUTPUT => array('pipe', 'wb'), // stdout
|
|
self::FD_ERROR => array('pipe', 'wb'), // stderr
|
|
self::FD_STATUS => array('pipe', 'wb'), // status
|
|
self::FD_COMMAND => array('pipe', 'rb'), // command
|
|
self::FD_MESSAGE => array('pipe', 'rb') // message
|
|
];
|
|
|
|
$this->_debug('OPENING GPG SUBPROCESS WITH THE FOLLOWING COMMAND:');
|
|
$this->_debug($commandLine);
|
|
|
|
// Don't localize GnuPG results.
|
|
$env = $_ENV;
|
|
$env['LC_ALL'] = 'C';
|
|
|
|
$proc_pipes = [];
|
|
|
|
$this->proc_resource = \proc_open(
|
|
$commandLine,
|
|
$descriptorSpec,
|
|
$proc_pipes,
|
|
null,
|
|
$env,
|
|
['binary_pipes' => true]
|
|
);
|
|
|
|
if (!\is_resource($this->proc_resource)) {
|
|
throw new \Exception('Unable to open GPG process.');
|
|
}
|
|
|
|
$this->_openPipes = new GpgProcPipes($proc_pipes);
|
|
|
|
$this->_debug('BEGIN PROCESSING');
|
|
|
|
$commandBuffer = ''; // buffers input to GPG
|
|
$messageBuffer = ''; // buffers input to GPG
|
|
$inputBuffer = ''; // buffers input to GPG
|
|
$outputBuffer = ''; // buffers output from GPG
|
|
$inputComplete = false; // input stream is completely buffered
|
|
$messageComplete = false; // message stream is completely buffered
|
|
|
|
if (\is_string($this->_input)) {
|
|
$inputBuffer = $this->_input;
|
|
$inputComplete = true;
|
|
}
|
|
|
|
if (\is_string($this->_message)) {
|
|
$messageBuffer = $this->_message;
|
|
$messageComplete = true;
|
|
}
|
|
|
|
$status = [];
|
|
$errors = [];
|
|
|
|
// convenience variables
|
|
$fdInput = $proc_pipes[self::FD_INPUT];
|
|
$fdOutput = $proc_pipes[self::FD_OUTPUT];
|
|
$fdError = $proc_pipes[self::FD_ERROR];
|
|
$fdStatus = $proc_pipes[self::FD_STATUS];
|
|
$fdCommand = $proc_pipes[self::FD_COMMAND];
|
|
$fdMessage = $proc_pipes[self::FD_MESSAGE];
|
|
|
|
// select loop delay in milliseconds
|
|
$delay = 0;
|
|
$inputPosition = 0;
|
|
|
|
$start = \microtime(1);
|
|
|
|
while (true) {
|
|
// Timeout after 5 seconds
|
|
if (5 < \microtime(1) - $start) {
|
|
$errors[] = 'timeout';
|
|
return [
|
|
'output' => '',
|
|
'status' => $status,
|
|
'errors' => $errors
|
|
];
|
|
exit;
|
|
}
|
|
|
|
$inputStreams = [];
|
|
$outputStreams = [];
|
|
$exceptionStreams = [];
|
|
|
|
// set up input streams
|
|
if (!$inputComplete && \is_resource($this->_input)) {
|
|
if (\feof($this->_input)) {
|
|
$inputComplete = true;
|
|
} else {
|
|
$inputStreams[] = $this->_input;
|
|
}
|
|
}
|
|
|
|
// close GPG input pipe if there is no more data
|
|
if ('' == $inputBuffer && $inputComplete) {
|
|
$this->_debug('=> closing GPG input pipe');
|
|
$this->_openPipes->close(self::FD_INPUT);
|
|
}
|
|
|
|
if (\is_resource($this->_message) && !$messageComplete) {
|
|
if (\feof($this->_message)) {
|
|
$messageComplete = true;
|
|
} else {
|
|
$inputStreams[] = $this->_message;
|
|
}
|
|
}
|
|
|
|
if (!\feof($fdOutput)) {
|
|
$inputStreams[] = $fdOutput;
|
|
}
|
|
|
|
if (!\feof($fdStatus)) {
|
|
$inputStreams[] = $fdStatus;
|
|
}
|
|
|
|
if (!\feof($fdError)) {
|
|
$inputStreams[] = $fdError;
|
|
}
|
|
|
|
// set up output streams
|
|
if ('' != $outputBuffer && $this->_output) {
|
|
$outputStreams[] = $this->_output;
|
|
}
|
|
|
|
if ($commandBuffer != '' && \is_resource($fdCommand)) {
|
|
$outputStreams[] = $fdCommand;
|
|
}
|
|
|
|
if ('' != $messageBuffer) {
|
|
if (\is_resource($fdMessage)) {
|
|
$outputStreams[] = $fdMessage;
|
|
}
|
|
} else if ($messageComplete) {
|
|
// close GPG message pipe if there is no more data
|
|
$this->_debug('=> closing GPG message pipe');
|
|
$this->_openPipes->close(self::FD_MESSAGE);
|
|
}
|
|
|
|
if ($inputBuffer != '' && \is_resource($fdInput)) {
|
|
$outputStreams[] = $fdInput;
|
|
}
|
|
|
|
// no streams left to read or write, we're all done
|
|
if (!\count($inputStreams) && !\count($outputStreams)) {
|
|
break;
|
|
}
|
|
|
|
$this->_debug('selecting streams');
|
|
|
|
$ready = \stream_select(
|
|
$inputStreams,
|
|
$outputStreams,
|
|
$exceptionStreams,
|
|
5
|
|
);
|
|
|
|
$this->_debug('=> got ' . $ready);
|
|
|
|
if ($ready === false) {
|
|
throw new \Exception(
|
|
'Error selecting stream for communication with GPG ' .
|
|
'subprocess. Please file a bug report at: ' .
|
|
'http://pear.php.net/bugs/report.php?package=Crypt_GPG'
|
|
);
|
|
}
|
|
|
|
if ($ready === 0) {
|
|
throw new \Exception(
|
|
'stream_select() returned 0. This can not happen! Please ' .
|
|
'file a bug report at: ' .
|
|
'http://pear.php.net/bugs/report.php?package=Crypt_GPG'
|
|
);
|
|
}
|
|
|
|
// write input (to GPG)
|
|
if (\in_array($fdInput, $outputStreams, true)) {
|
|
$this->_debug('GPG is ready for input');
|
|
$chunk = \substr($inputBuffer, $inputPosition, self::CHUNK_SIZE);
|
|
$length = \strlen($chunk);
|
|
$this->_debug('=> about to write ' . $length . ' bytes to GPG input');
|
|
$length = $this->_openPipes->writePipe(self::FD_INPUT, $chunk, $length);
|
|
if ($length) {
|
|
$this->_debug('=> wrote ' . $length . ' bytes');
|
|
// Move the position pointer, don't modify $inputBuffer (#21081)
|
|
if (\is_string($this->_input)) {
|
|
$inputPosition += $length;
|
|
} else {
|
|
$inputPosition = 0;
|
|
$inputBuffer = \substr($inputBuffer, $length);
|
|
}
|
|
} else {
|
|
$this->_debug('=> pipe broken and closed');
|
|
}
|
|
}
|
|
|
|
// read input (from PHP stream)
|
|
// If the buffer is too big wait until it's smaller, we don't want
|
|
// to use too much memory
|
|
if (\in_array($this->_input, $inputStreams, true) && \strlen($inputBuffer) < self::CHUNK_SIZE) {
|
|
$this->_debug('input stream is ready for reading');
|
|
$this->_debug('=> about to read ' . self::CHUNK_SIZE . ' bytes from input stream');
|
|
$chunk = \fread($this->_input, self::CHUNK_SIZE);
|
|
$length = \strlen($chunk);
|
|
$inputBuffer .= $chunk;
|
|
$this->_debug('=> read ' . $length . ' bytes');
|
|
}
|
|
|
|
// write message (to GPG)
|
|
if (\in_array($fdMessage, $outputStreams, true)) {
|
|
$this->_debug('GPG is ready for message data');
|
|
$this->_debug('=> about to write ' . \min(self::CHUNK_SIZE, \strlen($messageBuffer)) . ' bytes to GPG message');
|
|
$length = $this->_openPipes->writePipe(self::FD_MESSAGE, $messageBuffer, self::CHUNK_SIZE);
|
|
if ($length) {
|
|
$this->_debug('=> wrote ' . $length . ' bytes');
|
|
$messageBuffer = \substr($messageBuffer, $length);
|
|
} else {
|
|
$this->_debug('=> pipe broken and closed');
|
|
}
|
|
}
|
|
|
|
// read message (from PHP stream)
|
|
if (\in_array($this->_message, $inputStreams, true)) {
|
|
$this->_debug('message stream is ready for reading');
|
|
$this->_debug('=> about to read ' . self::CHUNK_SIZE . ' bytes from message stream');
|
|
$chunk = \fread($this->_message, self::CHUNK_SIZE);
|
|
$length = \strlen($chunk);
|
|
$messageBuffer .= $chunk;
|
|
$this->_debug('=> read ' . $length . ' bytes');
|
|
}
|
|
|
|
// read output (from GPG)
|
|
if (\in_array($fdOutput, $inputStreams, true)) {
|
|
$this->_debug('GPG output stream ready for reading');
|
|
$this->_debug('=> about to read ' . self::CHUNK_SIZE . ' bytes from GPG output');
|
|
$chunk = \fread($fdOutput, self::CHUNK_SIZE);
|
|
$length = \strlen($chunk);
|
|
$outputBuffer .= $chunk;
|
|
$this->_debug('=> read ' . $length . ' bytes');
|
|
}
|
|
|
|
// write output (to PHP stream)
|
|
if (\in_array($this->_output, $outputStreams, true)) {
|
|
$this->_debug('output stream is ready for data');
|
|
$chunk = \substr($outputBuffer, 0, self::CHUNK_SIZE);
|
|
$length = \strlen($chunk);
|
|
$this->_debug('=> about to write ' . $length . ' bytes to output stream');
|
|
$length = \fwrite($this->_output, $chunk, $length);
|
|
if (!$length) {
|
|
$this->_debug('=> broken pipe on output stream');
|
|
$this->_debug('=> closing pipe output stream');
|
|
$this->_openPipes->close(self::FD_OUTPUT);
|
|
} else {
|
|
$this->_debug('=> wrote ' . $length . ' bytes');
|
|
$outputBuffer = \substr($outputBuffer, $length);
|
|
}
|
|
}
|
|
|
|
// read error (from GPG)
|
|
if (\in_array($fdError, $inputStreams, true)) {
|
|
$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) {
|
|
$errors[] = $line;
|
|
$this->_debug("\t{$line}");
|
|
}
|
|
}
|
|
|
|
// read status (from GPG)
|
|
if (\in_array($fdStatus, $inputStreams, true)) {
|
|
$this->_debug('GPG status stream ready for reading');
|
|
$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) {
|
|
// only pass lines beginning with magic prefix
|
|
if ('[GNUPG:] ' == \substr($line, 0, 9)) {
|
|
$line = \substr($line, 9);
|
|
$status[] = $line;
|
|
$this->_debug("\t{$line}");
|
|
|
|
$tokens = \explode(' ', $line);
|
|
// NEED_PASSPHRASE 0123456789ABCDEF 0123456789ABCDEF 1 0
|
|
if ('NEED_PASSPHRASE' === $tokens[0]) {
|
|
// key ?: subkey
|
|
$passphrase = $this->getPassphrase($tokens[1]) ?: $this->getPassphrase($tokens[2]);
|
|
$commandBuffer .= $passphrase . PHP_EOL;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// write command (to GPG)
|
|
if (\in_array($fdCommand, $outputStreams, true)) {
|
|
$this->_debug('GPG is ready for command data');
|
|
$chunk = \substr($commandBuffer, 0, self::CHUNK_SIZE);
|
|
$length = \strlen($chunk);
|
|
$this->_debug('=> about to write ' . $length . ' bytes to GPG command');
|
|
$length = $this->_openPipes->writePipe(self::FD_COMMAND, $chunk, $length);
|
|
if ($length) {
|
|
$this->_debug('=> wrote ' . $length);
|
|
$commandBuffer = \substr($commandBuffer, $length);
|
|
} else {
|
|
$this->_debug('=> pipe broken and closed');
|
|
}
|
|
}
|
|
|
|
if (\count($outputStreams) === 0 || \count($inputStreams) === 0) {
|
|
// we have an I/O imbalance, increase the select loop delay
|
|
// to smooth things out
|
|
$delay += 10;
|
|
} else {
|
|
// things are running smoothly, decrease the delay
|
|
$delay -= 8;
|
|
$delay = \max(0, $delay);
|
|
}
|
|
|
|
if ($delay > 0) {
|
|
\usleep($delay);
|
|
}
|
|
|
|
} // end loop while streams are open
|
|
|
|
$this->_debug('END PROCESSING');
|
|
|
|
$this->proc_close();
|
|
|
|
$this->_message = null;
|
|
$this->_input = null;
|
|
$this->_output = null;
|
|
|
|
return [
|
|
'output' => $outputBuffer,
|
|
'status' => $status,
|
|
'errors' => $errors
|
|
];
|
|
}
|
|
|
|
private function getPassphrase($key)
|
|
{
|
|
$passphrase = '';
|
|
$keyIdLength = \strlen($key);
|
|
if ($keyIdLength && !empty($_ENV['PINENTRY_USER_DATA'])) {
|
|
$passphrases = \json_decode($_ENV['PINENTRY_USER_DATA'], true);
|
|
foreach ($passphrases as $keyId => $pass) {
|
|
$length = \min($keyIdLength, \strlen($keyId));
|
|
if (\substr($keyId, -$length) === \substr($key, -$length)) {
|
|
return $pass;
|
|
}
|
|
}
|
|
}
|
|
// throw new \Exception("Passphrase not found for {$key}");
|
|
return '';
|
|
}
|
|
|
|
private function proc_close() : int
|
|
{
|
|
$exitCode = 0;
|
|
|
|
// clear PINs from environment if they were set
|
|
$_ENV['PINENTRY_USER_DATA'] = null;
|
|
|
|
if (\is_resource($this->proc_resource)) {
|
|
$this->_debug('CLOSING GPG SUBPROCESS');
|
|
|
|
// close remaining open pipes
|
|
$this->_openPipes->closeAll();
|
|
|
|
$status = \proc_get_status($this->proc_resource);
|
|
$exitCode = \proc_close($this->proc_resource);
|
|
$this->proc_resource = null;
|
|
|
|
// proc_close() can return -1 in some cases,
|
|
// get the real exit code from the process status
|
|
if ($exitCode < 0 && $status && !$status['running']) {
|
|
$exitCode = $status['exitcode'];
|
|
}
|
|
|
|
if ($exitCode > 0) {
|
|
$this->_debug('=> subprocess returned an unexpected exit code: ' . $exitCode);
|
|
}
|
|
}
|
|
|
|
return $exitCode;
|
|
}
|
|
|
|
private static function findBinary($name) : ?string
|
|
{
|
|
$binary = \trim(`which $name`);
|
|
if ($binary && \is_executable($binary)) {
|
|
return $binary;
|
|
}
|
|
$locations = \array_filter([
|
|
'/sw/bin/',
|
|
'/usr/bin/',
|
|
'/usr/local/bin/',
|
|
'/opt/local/bin/',
|
|
'/run/current-system/sw/bin/'
|
|
], '\RainLoop\Utils::inOpenBasedir');
|
|
foreach ($locations as $location) {
|
|
if (\is_executable($location . $name)) {
|
|
return $location . $name;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
}
|
|
|
|
|
|
class GpgProcPipes
|
|
{
|
|
private $pipes;
|
|
|
|
function __construct(array $pipes)
|
|
{
|
|
// Set streams as non-blocking.
|
|
foreach ($pipes as $pipe) {
|
|
\stream_set_blocking($pipe, 0);
|
|
\stream_set_write_buffer($pipe, GPG::CHUNK_SIZE);
|
|
\stream_set_chunk_size($pipe, GPG::CHUNK_SIZE);
|
|
\stream_set_read_buffer($pipe, GPG::CHUNK_SIZE);
|
|
}
|
|
$this->pipes = $pipes;
|
|
}
|
|
|
|
function __destruct()
|
|
{
|
|
$this->closeAll();
|
|
}
|
|
|
|
public function closeAll() : void
|
|
{
|
|
foreach (\array_keys($this->pipes) as $number) {
|
|
$this->close($number);
|
|
}
|
|
}
|
|
|
|
public function get(int $number)
|
|
{
|
|
if (\array_key_exists($number, $this->pipes) && \is_resource($this->pipes[$number])) {
|
|
return $this->pipes[$number];
|
|
}
|
|
}
|
|
|
|
public function close(int $number) : void
|
|
{
|
|
if (\array_key_exists($number, $this->pipes)) {
|
|
\fflush($this->pipes[$number]);
|
|
\fclose($this->pipes[$number]);
|
|
unset($this->pipes[$number]);
|
|
}
|
|
}
|
|
|
|
private $buffers = [];
|
|
public function readPipeLines(int $number) : iterable
|
|
{
|
|
$pipe = $this->get($number);
|
|
if ($pipe) {
|
|
$chunk = \fread($pipe, GPG::CHUNK_SIZE);
|
|
$length = \strlen($chunk);
|
|
$eolLength = \strlen(PHP_EOL);
|
|
if (!isset($this->buffers[$number])) {
|
|
$this->buffers[$number] = '';
|
|
}
|
|
$this->buffers[$number] .= $chunk;
|
|
while (false !== ($pos = \strpos($this->buffers[$number], PHP_EOL))) {
|
|
yield \substr($this->buffers[$number], 0, $pos);
|
|
$this->buffers[$number] = \substr($this->buffers[$number], $pos + $eolLength);
|
|
}
|
|
}
|
|
}
|
|
|
|
public function writePipe(int $number, string $data, int $length = 0) : int
|
|
{
|
|
$pipe = $this->get($number);
|
|
if ($pipe) {
|
|
$chunk = \substr($data, 0, $length ?: \strlen($data));
|
|
$length = \strlen($chunk);
|
|
$length = \fwrite($pipe, $chunk, $length);
|
|
if (!$length) {
|
|
// If we wrote 0 bytes it was either EAGAIN or EPIPE. Since
|
|
// 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->close($number);
|
|
}
|
|
return $length ?: 0;
|
|
}
|
|
return 0;
|
|
}
|
|
}
|