pssht  latest
SSH server library written in PHP
Openssh.php
1 <?php
2 
3 /*
4 * This file is part of pssht.
5 *
6 * (c) François Poirotte <clicky@erebot.net>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11 
13 
21 class Openssh
22 {
24  const AUTH_MAGIC = "openssh-key-v1\x00";
25 
26  public static function loadPublic($data)
27  {
28  if (!is_string($data)) {
29  throw new \InvalidArgumentException();
30  }
31 
32  // Normalize spaces.
33  while (strpos($data, ' ') !== false) {
34  $data = str_replace(' ', ' ', $data);
35  }
36 
37  $algos = \fpoirotte\pssht\Algorithms::factory();
38 
39  // First, assume a key with no options.
40  $fields = explode(' ', $data, 3);
41  if (count($fields) < 2) {
42  throw new \InvalidArgumentException($data);
43  }
44  $type = strtolower($fields[0]);
45  $cls = $algos->getClass('Key', $type);
46 
47  // Try again, this time with options parsing.
48  if ($cls === null) {
49  while ($data !== '') {
50  $pos = strcspn($data, ' "');
51  $skipped = substr($data, 0, $pos);
52  $token = $data[$pos];
53  $data = (string) substr($data, $pos + 1);
54 
55  if ($data === '') {
56  throw new \InvalidArgumentException();
57  }
58 
59  if ($token === ' ') {
60  break;
61  }
62 
63  // Eat away everything until the closing '"'.
64  $pos = strpos($data, '"');
65  if ($pos === false) {
66  throw new \InvalidArgumentException();
67  }
68  $data = (string) substr($data, $pos + 1);
69  }
70  if ($data === '') {
71  throw new \InvalidArgumentException();
72  }
73 
74  // Remaining data: "<type> <key> [comment]".
75  $fields = explode(' ', $data, 3);
76  if (count($fields) < 2) {
77  throw new \InvalidArgumentException();
78  }
79 
80  $type = strtolower($fields[0]);
81  $cls = $algos->getClass('Key', $type);
82  }
83 
84  if ($cls === null) {
85  throw new \InvalidArgumentException();
86  }
87 
88  $key = base64_decode($fields[1]);
89  if ($key === false) {
90  throw new \InvalidArgumentException();
91  }
92 
93  $decoder = new \fpoirotte\Pssht\Wire\Decoder();
94  $decoder->getBuffer()->push($key);
95 
96  // Eat away the key type.
97  if ($decoder->decodeString() !== $type) {
98  throw new \InvalidArgumentException();
99  }
100  return $cls::unserialize($decoder);
101  }
102 
103  public static function loadPrivate($data, $passphrase = '')
104  {
105  if (!is_string($data)) {
106  throw new \InvalidArgumentException();
107  }
108 
109  if (!is_string($passphrase)) {
110  throw new \InvalidArgumentException();
111  }
112 
113  $key = openssl_pkey_get_private($data, $passphrase);
114  $details = false;
115  if ($key !== false) {
116  $details = openssl_pkey_get_details($key);
117  }
118 
119  if ($key === false || $details === false || $details['type'] === -1) {
120  // Either this is not an OpenSSH private key or it uses a format
121  // which is not recognized by the PHP OpenSSL extension.
122  // Eg. it is an Ed25519 private key, encoded using
123  // the new OpenSSH private key file format.
124  //
125  // So, we try to parse it ourselves...
126  // Encrypted private keys are not yet supported.
127  return self::parseUnknown(
128  $data,
129  $passphrase
130  );
131  }
132 
133  $algos = \fpoirotte\pssht\Algorithms::factory();
134  switch ($details['type']) {
135  case OPENSSL_KEYTYPE_EC:
136  // The PHP OpenSSL extension does not handle ECDSA keys
137  // properly yet ($details does not actually contain any
138  // useful information about the public/private key).
139  //
140  // So we have to parse it manually for now.
141  // Again, encrypted private keys are not yet supported.
142  return self::parseECDSA($data, $passphrase);
143 
144  case OPENSSL_KEYTYPE_RSA:
145  $cls = $algos->getClass('Key', 'ssh-rsa');
146  return new $cls(
147  gmp_init(bin2hex($details['rsa']['n']), 16),
148  gmp_init(bin2hex($details['rsa']['e']), 16),
149  gmp_init(bin2hex($details['rsa']['d']), 16)
150  );
151 
152  case OPENSSL_KEYTYPE_DSA:
153  $cls = $algos->getClass('Key', 'ssh-dss');
154  return new $cls(
155  gmp_init(bin2hex($details['dsa']['p']), 16),
156  gmp_init(bin2hex($details['dsa']['q']), 16),
157  gmp_init(bin2hex($details['dsa']['g']), 16),
158  gmp_init(bin2hex($details['dsa']['pub_key']), 16),
159  gmp_init(bin2hex($details['dsa']['priv_key']), 16)
160  );
161 
162  default:
163  throw new \InvalidArgumentException('Invalid key');
164  }
165  }
166 
180  private static function parseUnknown($data, $passphrase)
181  {
183  return self::parseOpensshPrivateKey($data, $passphrase);
184  }
185 
201  private static function parseOpensshPrivateKey($data, $passphrase)
202  {
203  if ($passphrase !== '') {
204  throw new \RuntimeException();
205  }
206 
207  // For now, this is the only type of key we support.
208  $type = 'ssh-ed25519';
209 
210  $header = '-----BEGIN OPENSSH PRIVATE KEY-----';
211  $footer = '-----END OPENSSH PRIVATE KEY-----';
212  $data = trim($data);
213  if (strncmp($data, $header, strlen($header)) !== 0) {
214  throw new \InvalidArgumentException();
215  } elseif (substr($data, -strlen($footer)) !== $footer) {
216  throw new \InvalidArgumentException();
217  }
218  $key = base64_decode(substr($data, strlen($header), -strlen($footer)));
219 
220  if (strncmp($key, static::AUTH_MAGIC, strlen(static::AUTH_MAGIC))) {
221  throw new \InvalidArgumentException();
222  }
223 
224  $decoder = new \fpoirotte\Pssht\Wire\Decoder();
225  $decoder->getBuffer()->push(substr($key, strlen(static::AUTH_MAGIC)));
226 
227  $ciphername = $decoder->decodeString();
228 
230  if ($ciphername !== 'none') {
231  throw new \InvalidArgumentException();
232  }
233 
234  $kdfname = $decoder->decodeString();
235  $kdfoptions = $decoder->decodeString();
236 
237  $numKeys = $decoder->decodeUint32();
238  $publicKeys = array();
239 
240  // Block malicious inputs
241  if ($numKeys <= 0 || $numKeys >= 0x80000000) {
242  throw new \InvalidArgumentException();
243  }
244 
245  for ($i = 0; $i < $numKeys; $i++) {
246  $tmp = new \fpoirotte\Pssht\Wire\Decoder();
247  $tmp->getBuffer()->push($decoder->decodeString());
248 
249  // Reject unknown key identifiers
250  if ($tmp->decodeString() !== $type) {
251  continue;
252  }
253 
254  $publicKeys[$i] = $tmp->decodeString();
255  }
256 
257  $decoder->getBuffer()->push($decoder->decodeString());
258 
259  // Both "checkint" fields must have the same value.
260  if ($decoder->decodeUint32() !== $decoder->decodeUint32()) {
261  throw new \InvalidArgumentException();
262  }
263 
264  // Reject unknown identifiers.
265  if ($decoder->decodeString() !== $type) {
266  throw new \InvalidArgumentException();
267  }
268 
269  // Discard public key blob (duplicate).
270  $decoder->decodeString();
271 
272  $secretKeys = array();
273  for ($i = 0; $i < $numKeys; $i++) {
274  $secretKeys[$i] = $decoder->decodeString();
275  // Discard comment field.
276  $tmp->decodeString();
277  }
278 
279  // Should we also ensure that a correct padding
280  // has been applied?
281 
282  $pk = reset($publicKeys);
283  if (!isset($secretKeys[key($publicKeys)])) {
284  throw new \InvalidArgumentException();
285  }
286  $sk = $secretKeys[key($publicKeys)];
287 
288  $algos = \fpoirotte\pssht\Algorithms::factory();
289  $cls = $algos->getClass('Key', 'ssh-ed25519');
291  return new $cls($pk, substr($sk, 0, 32));
292  }
293 
294  private static function parseECDSA($data, $passphrase)
295  {
296  if ($passphrase !== '') {
297  throw new \RuntimeException();
298  }
299 
300  $key = str_replace(array("\r", "\n"), '', $data);
301  $header = '-----BEGIN EC PRIVATE KEY-----';
302  $footer = '-----END EC PRIVATE KEY-----';
303  if (strncmp($key, $header, strlen($header)) !== 0) {
304  throw new \InvalidArgumentException();
305  } elseif (substr($key, -strlen($footer)) !== $footer) {
306  throw new \InvalidArgumentException();
307  }
308  $key = base64_decode(substr($key, strlen($header), -strlen($footer)));
309 
310  if ($key === false || strncmp($key, "\x30\x77\x02\x01\x01\x04", 6) !== 0) {
311  throw new \InvalidArgumentException();
312  }
313  $key = substr($key, 6);
314 
315  $len = ord($key[0]);
316  $privkey = gmp_init(bin2hex(substr($key, 1, $len)), 16);
317  $key = substr($key, $len + 1);
318 
319  if ($key[0] !== "\xA0" || $key[2] !== "\x06") {
320  throw new \InvalidArgumentException();
321  }
322  $len = ord($key[3]);
323  if ($len + 2 !== ord($key[1])) {
324  throw new \InvalidArgumentException();
325  }
326  $oid = substr($key, 4, $len);
327  $key = substr($key, $len + 4);
328 
329  if ($key[0] !== "\xA1" || $key[2] !== "\x03") {
330  throw new \InvalidArgumentException();
331  }
332  $len = ord($key[3]);
333  if ($len + 2 !== ord($key[1]) || strlen($key) !== $len + 4) {
334  throw new \InvalidArgumentException();
335  }
336 
337  // Map each curves' OID to its curve domain parameter identifier.
338  // See RFC 5656, Section 10.1 for more information.
339  $curves = array(
340  self::encodeOID('1.2.840.10045.3.1.7') => 'nistp256',
341  self::encodeOID('1.3.132.0.34') => 'nistp384',
342  self::encodeOID('1.3.132.0.35') => 'nistp521',
343  );
344  if (!isset($curves[$oid])) {
345  throw new \InvalidArgumentException();
346  }
347  $curve = \fpoirotte\Pssht\ECC\Curve::getCurve($curves[$oid]);
348  $pubkey = \fpoirotte\Pssht\ECC\Point::unserialize(
349  $curve,
350  ltrim(substr($key, 4), "\x00")
351  );
352  $pubkey2 = $curve->getGenerator()->multiply($curve, $privkey);
353 
354  if (gmp_strval($pubkey->x) !== gmp_strval($pubkey2->x) ||
355  gmp_strval($pubkey->y) !== gmp_strval($pubkey2->y)) {
356  throw new \InvalidArgumentException();
357  }
358 
359  $algos = \fpoirotte\pssht\Algorithms::factory();
360  $cls = $algos->getClass('Key', 'ecdsa-sha2-' . $curves[$oid]);
361  return new $cls($pubkey, $privkey);
362  }
363 
377  public static function encodeOID($oid)
378  {
379  if (strspn($oid, '1234567890.') !== strlen($oid)) {
380  throw new \InvalidArgumentException();
381  }
382 
383  $parts = explode('.', trim($oid, '.'));
384  $root = ((int) array_shift($parts)) * 40;
385  $root += (int) array_shift($parts);
386  $res = chr($root);
387 
388  foreach ($parts as $part) {
389  if ($part === '') {
390  throw new \InvalidArgumentException();
391  }
392 
393  $nb = (int) $part;
394  if ($nb >= 0 && $nb < 128) {
395  $res .= chr($nb);
396  continue;
397  }
398 
399  $part = gmp_strval(gmp_init($part, 10), 2);
400  $len = (int) ((strlen($part) + 6) / 7);
401  $part = str_split(str_pad($part, $len * 7, '0', STR_PAD_LEFT), 7);
402  foreach ($part as $index => $bits) {
403  $res .= chr((($index + 1 < $len) << 7) + bindec($bits));
404  }
405  }
406  return $res;
407  }
408 }
static parseOpensshPrivateKey($data, $passphrase)
Definition: Openssh.php:201
const AUTH_MAGIC
Magic value used to identity OpenSSH private keys.
Definition: Openssh.php:24
static parseUnknown($data, $passphrase)
Definition: Openssh.php:180