Skip to content

Commit b0701ff

Browse files
committed
Experimental key editor
1 parent 137dbfd commit b0701ff

4 files changed

Lines changed: 498 additions & 0 deletions

File tree

Crypt/GPG.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,16 @@ class Crypt_GPG extends Crypt_GPGAbstract
235235
*/
236236
protected $passphrases = [];
237237

238+
/**
239+
* Get a key editor instance
240+
*
241+
* @return Crypt_GPG_KeyEditor Key editor object
242+
*/
243+
public function getKeyEditor()
244+
{
245+
return $this->engine->getKeyEditor();
246+
}
247+
238248
/**
239249
* Imports a public or private key into the keyring
240250
*

Crypt/GPG/Engine.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@
5959
*/
6060
require_once 'Crypt/GPG/SignatureCreationInfo.php';
6161

62+
/**
63+
* Key editor
64+
*/
65+
require_once 'Crypt/GPG/KeyEditor.php';
66+
6267
/**
6368
* Standard PEAR exception is used if GPG binary is not found.
6469
*/
@@ -999,6 +1004,29 @@ public function setProcessData($name, $value)
9991004
}
10001005
}
10011006

1007+
/**
1008+
* Initialize key editor instance
1009+
*
1010+
* @return Crypt_GPG_KeyEditor Key editor object
1011+
*/
1012+
public function getKeyEditor()
1013+
{
1014+
$keys = ['homedir', 'binary', 'publicKeyring', 'privateKeyring', 'trustDb'];
1015+
$options = [];
1016+
1017+
foreach ($keys as $key) {
1018+
if (isset($this->{"_{$key}"})) {
1019+
$options[$key] = $this->{"_{$key}"};
1020+
}
1021+
}
1022+
1023+
if ($this->_debug) {
1024+
$options['debug'] = function ($line) { $this->_debug($line); };
1025+
}
1026+
1027+
return new Crypt_GPG_KeyEditor($this, $options);
1028+
}
1029+
10021030
/**
10031031
* Displays debug output for status lines
10041032
*

Crypt/GPG/KeyEditor.php

Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
1+
<?php
2+
3+
/* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */
4+
5+
require_once 'Crypt/GPG/Engine.php';
6+
7+
/**
8+
* A class for editing keys (using GnuPG interactive --key-edit shell)
9+
*
10+
* LICENSE:
11+
*
12+
* This library is free software; you can redistribute it and/or modify
13+
* it under the terms of the GNU Lesser General Public License as
14+
* published by the Free Software Foundation; either version 2.1 of the
15+
* License, or (at your option) any later version.
16+
*
17+
* This library is distributed in the hope that it will be useful,
18+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
19+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
20+
* Lesser General Public License for more details.
21+
*
22+
* You should have received a copy of the GNU Lesser General Public
23+
* License along with this library; if not, see
24+
* <http://www.gnu.org/licenses/>
25+
*
26+
* @category Encryption
27+
* @package Crypt_GPG
28+
* @author Aleksander Machniak <machniak@apheleia-it.ch>
29+
* @copyright Apheleia IT AG <contact@apheleia-it.ch>
30+
* @license http://www.gnu.org/copyleft/lesser.html LGPL License 2.1
31+
* @link http://pear.php.net/package/Crypt_GPG
32+
*/
33+
class Crypt_GPG_KeyEditor
34+
{
35+
/** @var array The GnuPG engine/key editor options */
36+
protected $options;
37+
38+
/** @var Crypt_GPG_Engine The GnuPG engine */
39+
protected $engine;
40+
41+
protected $key;
42+
protected $passphrase = '';
43+
protected $process;
44+
protected $pipes = [];
45+
46+
/**
47+
* Creates a new key editor
48+
*
49+
* @param Crypt_GPG_Engine $engine GnuPG engine
50+
* @param array $options GnuPG engine options
51+
*/
52+
public function __construct($engine, array $options)
53+
{
54+
$this->engine = $engine;
55+
$this->options = $options;
56+
}
57+
58+
/**
59+
* Closes open GPG subprocesses when this object is destroyed.
60+
*
61+
* Subprocesses should never be left open by this class unless there is
62+
* an unknown error and an unexpected script termination occured.
63+
*/
64+
public function __destruct()
65+
{
66+
$this->_close();
67+
}
68+
69+
/**
70+
* Starts key editing session
71+
*
72+
* @param mixed $key The key to use. This may be a key identifier, user id, fingerprint,
73+
* {@link Crypt_GPG_Key} or {@link Crypt_GPG_SubKey}.
74+
* @param string $passphrase The passphrase of the key required for signing (optional).
75+
*
76+
* @return Crypt_GPG_KeyEditor The current object, for fluent interface.
77+
*
78+
* @sensitive $passphrase
79+
*/
80+
public function edit($key, $passphrase = null)
81+
{
82+
if ($this->key && $this->process) {
83+
$this->save();
84+
$this->_close();
85+
}
86+
87+
$this->key = (string) $key;
88+
$this->passphrase = (string) $passphrase;
89+
90+
$version = $this->engine->getVersion();
91+
92+
// Since 2.1.13 we can use "loopback mode" instead of gpg-agent
93+
// We do not support older versions here
94+
if (!version_compare($version, '2.1.13', 'ge')) {
95+
throw new PEAR_Exception("Key editor requires GnuPG >= 2.1.13");
96+
}
97+
98+
$arguments = [
99+
'--no-default-keyring', // ignored if keyring files are not specified
100+
'--no-options', // prevent creation of ~/.gnupg directory
101+
'--no-permission-warning',
102+
'--trust-model always',
103+
'--homedir ' . escapeshellarg($this->options['homedir']),
104+
'--pinentry-mode loopback', // passphrase input in stdin
105+
'--command-fd ' . escapeshellarg(Crypt_GPG_Engine::FD_INPUT),
106+
'--status-fd ' . escapeshellarg(Crypt_GPG_Engine::FD_ERROR),
107+
'--batch',
108+
'--yes',
109+
];
110+
111+
// the random seed file makes subsequent actions faster so only
112+
// disable it if we have to.
113+
if (!is_writeable($this->options['homedir'])) {
114+
$arguments[] = '--no-random-seed-file';
115+
}
116+
117+
if (!empty($this->options['publicKeyring'])) {
118+
$arguments[] = '--keyring ' . escapeshellarg($this->options['publicKeyring']);
119+
}
120+
121+
if (!empty($this->options['privateKeyring'])) {
122+
$arguments[] = '--secret-keyring ' . escapeshellarg($this->options['privateKeyring']);
123+
}
124+
125+
if (!empty($this->options['trustDb'])) {
126+
$arguments[] = '--trustdb-name ' . escapeshellarg($this->options['trustDb']);
127+
}
128+
129+
$command = $this->options['binary'] . ' ' . implode(' ', $arguments) . ' --edit-key ' . escapeshellarg($this->key);
130+
131+
$this->_debug("OPENING GPG SUBPROCESS WITH THE FOLLOWING COMMAND:");
132+
$this->_debug($command);
133+
134+
// Get environment variables. Exclude non-scalar values to prevent from a warning in proc_open().
135+
// Possibly related to https://bugs.php.net/bug.php?id=75712, which was fixed in PHP 8.2.17.
136+
$env = array_filter($_ENV, 'is_scalar');
137+
138+
// Newer versions of GnuPG return localized results. Crypt_GPG only
139+
// works with English, so set the locale to 'C' for the subprocess.
140+
$env['LC_ALL'] = 'C';
141+
142+
$specs = [
143+
Crypt_GPG_Engine::FD_INPUT => ['pipe', 'r'],
144+
// Crypt_GPG_Engine::FD_OUTPUT => ['pipe', 'w'],
145+
Crypt_GPG_Engine::FD_ERROR => ['pipe', 'w'],
146+
];
147+
148+
$this->process = proc_open($command, $specs, $this->pipes, null, $env);
149+
150+
if (!is_resource($this->process)) {
151+
throw new Crypt_GPG_OpenSubprocessException('Unable to open GPG subprocess.', 0, $command);
152+
}
153+
154+
// Set streams as non-blocking
155+
foreach ($this->pipes as $pipe) {
156+
stream_set_blocking($pipe, 0);
157+
stream_set_write_buffer($pipe, 0);
158+
stream_set_read_buffer($pipe, 0);
159+
}
160+
161+
$this->_read([], ['keyedit.prompt']);
162+
163+
if (feof($this->pipes[Crypt_GPG_Engine::FD_ERROR])) {
164+
$this->_close();
165+
throw new Crypt_GPG_OpenSubprocessException('Failed to open GPG subprocess (key not found?).', 0, $command);
166+
}
167+
168+
return $this;
169+
}
170+
171+
/**
172+
* Add a user identity to a key (`adduid`).
173+
*
174+
* @return Crypt_GPG_KeyEditor The current object, for fluent interface.
175+
*/
176+
public function addUserId(Crypt_GPG_UserId $userid)
177+
{
178+
$handlers = [
179+
'keygen.name' => $userid->getName(),
180+
'keygen.email' => $userid->getEmail(),
181+
'keygen.comment' => $userid->getComment(),
182+
'passphrase.enter' => $this->passphrase,
183+
];
184+
185+
$this->_write('adduid')->_read($handlers, ['keyedit.prompt']);
186+
187+
return $this;
188+
}
189+
190+
/**
191+
* Delete a user identity from a key (`deluid`).
192+
*
193+
* @return Crypt_GPG_KeyEditor The current object, for fluent interface.
194+
*/
195+
public function deleteUserId(Crypt_GPG_UserId $userid)
196+
{
197+
// TODO: Find the identity index (`uid 0`), call `uid X`, call `deluid`.
198+
return $this;
199+
}
200+
201+
/**
202+
* Quit the current editing session without saving changes (`quit`).
203+
*
204+
* @return Crypt_GPG_KeyEditor The current object, for fluent interface.
205+
*/
206+
public function quit()
207+
{
208+
$this->_write('quit')->_read(['keyedit.save.okay' => 'N']);
209+
$this->_close();
210+
return $this;
211+
}
212+
213+
/**
214+
* Save the changes and exit (`save`).
215+
*
216+
* @return Crypt_GPG_KeyEditor The current object, for fluent interface.
217+
*/
218+
public function save()
219+
{
220+
$this->_write('save')->_read();
221+
$this->_close();
222+
return $this;
223+
}
224+
225+
/**
226+
* Close the process
227+
*/
228+
private function _close()
229+
{
230+
foreach ($this->pipes as $pipe) {
231+
fflush($pipe);
232+
fclose($pipe);
233+
}
234+
235+
if ($this->process) {
236+
proc_close($this->process);
237+
238+
$this->_debug("CLOSED GPG SUBPROCESS");
239+
}
240+
241+
$this->process = null;
242+
$this->pipes = [];
243+
}
244+
245+
/**
246+
* Read process output
247+
*/
248+
private function _read($handlers = [], $stop_at = [])
249+
{
250+
$output = '';
251+
$passInput = false;
252+
253+
while (true) {
254+
$inputStreams = [];
255+
$outputStreams = [];
256+
$exceptionStreams = [];
257+
258+
if (!feof($this->pipes[Crypt_GPG_Engine::FD_ERROR])) {
259+
$inputStreams[] = $this->pipes[Crypt_GPG_Engine::FD_ERROR];
260+
}
261+
262+
if (count($inputStreams) === 0) {
263+
break;
264+
}
265+
266+
$ready = stream_select($inputStreams, $outputStreams, $exceptionStreams, null);
267+
268+
if ($ready === false || $ready === 0) {
269+
throw new Crypt_GPG_Exception('Error selecting stream for communication with GPG subprocess');
270+
}
271+
272+
if (in_array($this->pipes[Crypt_GPG_Engine::FD_ERROR], $inputStreams, true)) {
273+
$line = fgets($this->pipes[Crypt_GPG_Engine::FD_ERROR], 8192);
274+
if ($line === false) {
275+
break;
276+
}
277+
278+
$this->_debug(rtrim("> $line"));
279+
280+
$output .= $line;
281+
282+
if (preg_match('/(GET_LINE|GET_HIDDEN|GET_BOOL) ([a-z.]+)/', $line, $matches)) {
283+
$token = $matches[2];
284+
285+
if (isset($handlers[$token])) {
286+
if (is_string($handlers[$token])) {
287+
$this->_write($handlers[$token]);
288+
} elseif (is_callable($handlers[$token])) {
289+
$handlers[$token]($token, $output);
290+
}
291+
292+
$output = '';
293+
}
294+
295+
if (in_array($token, $stop_at)) {
296+
break;
297+
}
298+
299+
$passInput = $token == 'passphrase.enter';
300+
}
301+
302+
if ($passInput && strpos($line, 'Bad passphrase')) {
303+
$this->_close();
304+
throw new Crypt_GPG_BadPassphraseException('Missing or wrong key passphrase');
305+
}
306+
}
307+
308+
usleep(10);
309+
}
310+
311+
return $output;
312+
}
313+
314+
/**
315+
* Write to the process input
316+
*/
317+
private function _write($input)
318+
{
319+
$this->_debug("< $input");
320+
321+
fwrite($this->pipes[Crypt_GPG_Engine::FD_INPUT], "$input\n");
322+
fflush($this->pipes[Crypt_GPG_Engine::FD_INPUT]);
323+
324+
return $this;
325+
}
326+
327+
/**
328+
* Log debug information
329+
*/
330+
private function _debug($line)
331+
{
332+
if (!empty($this->options['debug'])) {
333+
call_user_func($this->options['debug'], $line);
334+
}
335+
}
336+
}

0 commit comments

Comments
 (0)