1<?php
2
3/**
4 * PuTTY Formatted Key Handler
5 *
6 * See PuTTY's SSHPUBK.C and https://tartarus.org/~simon/putty-snapshots/htmldoc/AppendixC.html
7 *
8 * PHP version 5
9 *
10 * @category  Crypt
11 * @package   Common
12 * @author    Jim Wigginton <terrafrost@php.net>
13 * @copyright 2016 Jim Wigginton
14 * @license   http://www.opensource.org/licenses/mit-license.html  MIT License
15 * @link      http://phpseclib.sourceforge.net
16 */
17
18namespace phpseclib3\Crypt\Common\Formats\Keys;
19
20use ParagonIE\ConstantTime\Base64;
21use ParagonIE\ConstantTime\Hex;
22use phpseclib3\Common\Functions\Strings;
23use phpseclib3\Crypt\AES;
24use phpseclib3\Crypt\Hash;
25use phpseclib3\Crypt\Random;
26use phpseclib3\Exception\UnsupportedAlgorithmException;
27
28/**
29 * PuTTY Formatted Key Handler
30 *
31 * @package Common
32 * @author  Jim Wigginton <terrafrost@php.net>
33 * @access  public
34 */
35abstract class PuTTY
36{
37    /**
38     * Default comment
39     *
40     * @var string
41     * @access private
42     */
43    private static $comment = 'phpseclib-generated-key';
44
45    /**
46     * Default version
47     *
48     * @var int
49     * @access private
50     */
51    private static $version = 2;
52
53    /**
54     * Sets the default comment
55     *
56     * @access public
57     * @param string $comment
58     */
59    public static function setComment($comment)
60    {
61        self::$comment = str_replace(["\r", "\n"], '', $comment);
62    }
63
64    /**
65     * Sets the default version
66     *
67     * @access public
68     * @param int $version
69     */
70    public static function setVersion($version)
71    {
72        if ($version != 2 && $version != 3) {
73            throw new \RuntimeException('Only supported versions are 2 and 3');
74        }
75        self::$version = $version;
76    }
77
78    /**
79     * Generate a symmetric key for PuTTY v2 keys
80     *
81     * @access private
82     * @param string $password
83     * @param int $length
84     * @return string
85     */
86    private static function generateV2Key($password, $length)
87    {
88        $symkey = '';
89        $sequence = 0;
90        while (strlen($symkey) < $length) {
91            $temp = pack('Na*', $sequence++, $password);
92            $symkey .= Hex::decode(sha1($temp));
93        }
94        return substr($symkey, 0, $length);
95    }
96
97    /**
98     * Generate a symmetric key for PuTTY v3 keys
99     *
100     * @access private
101     * @param string $password
102     * @param string $flavour
103     * @param int $memory
104     * @param int $passes
105     * @param string $salt
106     * @return array
107     */
108    private static function generateV3Key($password, $flavour, $memory, $passes, $salt)
109    {
110        if (!function_exists('sodium_crypto_pwhash')) {
111            throw new \RuntimeException('sodium_crypto_pwhash needs to exist for Argon2 password hasing');
112        }
113
114        switch ($flavour) {
115            case 'Argon2i':
116                $flavour = SODIUM_CRYPTO_PWHASH_ALG_ARGON2I13;
117                break;
118            case 'Argon2id':
119                $flavour = SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13;
120                break;
121            default:
122                throw new UnsupportedAlgorithmException('Only Argon2i and Argon2id are supported');
123        }
124
125        $length = 80; // keylen + ivlen + mac_keylen
126        $temp = sodium_crypto_pwhash($length, $password, $salt, $passes, $memory << 10, $flavour);
127
128        $symkey = substr($temp, 0, 32);
129        $symiv = substr($temp, 32, 16);
130        $hashkey = substr($temp, -32);
131
132        return compact('symkey', 'symiv', 'hashkey');
133    }
134
135    /**
136     * Break a public or private key down into its constituent components
137     *
138     * @access public
139     * @param string $key
140     * @param string $password
141     * @return array
142     */
143    public static function load($key, $password)
144    {
145        if (!Strings::is_stringable($key)) {
146            throw new \UnexpectedValueException('Key should be a string - not a ' . gettype($key));
147        }
148
149        if (strpos($key, 'BEGIN SSH2 PUBLIC KEY') !== false) {
150            $lines = preg_split('#[\r\n]+#', $key);
151            switch (true) {
152                case $lines[0] != '---- BEGIN SSH2 PUBLIC KEY ----':
153                    throw new \UnexpectedValueException('Key doesn\'t start with ---- BEGIN SSH2 PUBLIC KEY ----');
154                case $lines[count($lines) - 1] != '---- END SSH2 PUBLIC KEY ----':
155                    throw new \UnexpectedValueException('Key doesn\'t end with ---- END SSH2 PUBLIC KEY ----');
156            }
157            $lines = array_splice($lines, 1, -1);
158            $lines = array_map(function ($line) {
159                return rtrim($line, "\r\n");
160            }, $lines);
161            $data = $current = '';
162            $values = [];
163            $in_value = false;
164            foreach ($lines as $line) {
165                switch (true) {
166                    case preg_match('#^(.*?): (.*)#', $line, $match):
167                        $in_value = $line[strlen($line) - 1] == '\\';
168                        $current = strtolower($match[1]);
169                        $values[$current] = $in_value ? substr($match[2], 0, -1) : $match[2];
170                        break;
171                    case $in_value:
172                        $in_value = $line[strlen($line) - 1] == '\\';
173                        $values[$current] .= $in_value ? substr($line, 0, -1) : $line;
174                        break;
175                    default:
176                        $data .= $line;
177                }
178            }
179
180            $components = call_user_func([static::PUBLIC_HANDLER, 'load'], $data);
181            if ($components === false) {
182                throw new \UnexpectedValueException('Unable to decode public key');
183            }
184            $components += $values;
185            $components['comment'] = str_replace(['\\\\', '\"'], ['\\', '"'], $values['comment']);
186
187            return $components;
188        }
189
190        $components = [];
191
192        $key = preg_split('#\r\n|\r|\n#', trim($key));
193        if (Strings::shift($key[0], strlen('PuTTY-User-Key-File-')) != 'PuTTY-User-Key-File-') {
194            return false;
195        }
196        $version = (int) Strings::shift($key[0], 3); // should be either "2: " or "3: 0" prior to int casting
197        if ($version != 2 && $version != 3) {
198            throw new \RuntimeException('Only v2 and v3 PuTTY private keys are supported');
199        }
200        $components['type'] = $type = rtrim($key[0]);
201        if (!in_array($type, static::$types)) {
202            $error = count(static::$types) == 1 ?
203                'Only ' . static::$types[0] . ' keys are supported. ' :
204                '';
205            throw new UnsupportedAlgorithmException($error . 'This is an unsupported ' . $type . ' key');
206        }
207        $encryption = trim(preg_replace('#Encryption: (.+)#', '$1', $key[1]));
208        $components['comment'] = trim(preg_replace('#Comment: (.+)#', '$1', $key[2]));
209
210        $publicLength = trim(preg_replace('#Public-Lines: (\d+)#', '$1', $key[3]));
211        $public = Base64::decode(implode('', array_map('trim', array_slice($key, 4, $publicLength))));
212
213        $source = Strings::packSSH2('ssss', $type, $encryption, $components['comment'], $public);
214
215        extract(unpack('Nlength', Strings::shift($public, 4)));
216        $newtype = Strings::shift($public, $length);
217        if ($newtype != $type) {
218            throw new \RuntimeException('The binary type does not match the human readable type field');
219        }
220
221        $components['public'] = $public;
222
223        switch ($version) {
224            case 3:
225                $hashkey = '';
226                break;
227            case 2:
228                $hashkey = 'putty-private-key-file-mac-key';
229        }
230
231        $offset = $publicLength + 4;
232        switch ($encryption) {
233            case 'aes256-cbc':
234                $crypto = new AES('cbc');
235                switch ($version) {
236                    case 3:
237                        $flavour = trim(preg_replace('#Key-Derivation: (.*)#', '$1', $key[$offset++]));
238                        $memory = trim(preg_replace('#Argon2-Memory: (\d+)#', '$1', $key[$offset++]));
239                        $passes = trim(preg_replace('#Argon2-Passes: (\d+)#', '$1', $key[$offset++]));
240                        $parallelism = trim(preg_replace('#Argon2-Parallelism: (\d+)#', '$1', $key[$offset++]));
241                        $salt = Hex::decode(trim(preg_replace('#Argon2-Salt: ([0-9a-f]+)#', '$1', $key[$offset++])));
242
243                        extract(self::generateV3Key($password, $flavour, $memory, $passes, $salt));
244
245                        break;
246                    case 2:
247                        $symkey = self::generateV2Key($password, 32);
248                        $symiv = str_repeat("\0", $crypto->getBlockLength() >> 3);
249                        $hashkey .= $password;
250                }
251        }
252
253        switch ($version) {
254            case 3:
255                $hash = new Hash('sha256');
256                $hash->setKey($hashkey);
257                break;
258            case 2:
259                $hash = new Hash('sha1');
260                $hash->setKey(sha1($hashkey, true));
261        }
262
263        $privateLength = trim(preg_replace('#Private-Lines: (\d+)#', '$1', $key[$offset++]));
264        $private = Base64::decode(implode('', array_map('trim', array_slice($key, $offset, $privateLength))));
265
266        if ($encryption != 'none') {
267            $crypto->setKey($symkey);
268            $crypto->setIV($symiv);
269            $crypto->disablePadding();
270            $private = $crypto->decrypt($private);
271        }
272
273        $source .= Strings::packSSH2('s', $private);
274
275        $hmac = trim(preg_replace('#Private-MAC: (.+)#', '$1', $key[$offset + $privateLength]));
276        $hmac = Hex::decode($hmac);
277
278        if (!hash_equals($hash->hash($source), $hmac)) {
279            throw new \UnexpectedValueException('MAC validation error');
280        }
281
282        $components['private'] = $private;
283
284        return $components;
285    }
286
287    /**
288     * Wrap a private key appropriately
289     *
290     * @access private
291     * @param string $public
292     * @param string $private
293     * @param string $type
294     * @param string $password
295     * @param array $options optional
296     * @return string
297     */
298    protected static function wrapPrivateKey($public, $private, $type, $password, array $options = [])
299    {
300        $encryption = (!empty($password) || is_string($password)) ? 'aes256-cbc' : 'none';
301        $comment = isset($options['comment']) ? $options['comment'] : self::$comment;
302        $version = isset($options['version']) ? $options['version'] : self::$version;
303
304        $key = "PuTTY-User-Key-File-$version: $type\r\n";
305        $key .= "Encryption: $encryption\r\n";
306        $key .= "Comment: $comment\r\n";
307
308        $public = Strings::packSSH2('s', $type) . $public;
309
310        $source = Strings::packSSH2('ssss', $type, $encryption, $comment, $public);
311
312        $public = Base64::encode($public);
313        $key .= "Public-Lines: " . ((strlen($public) + 63) >> 6) . "\r\n";
314        $key .= chunk_split($public, 64);
315
316        if (empty($password) && !is_string($password)) {
317            $source .= Strings::packSSH2('s', $private);
318            switch ($version) {
319                case 3:
320                    $hash = new Hash('sha256');
321                    $hash->setKey('');
322                    break;
323                case 2:
324                    $hash = new Hash('sha1');
325                    $hash->setKey(sha1('putty-private-key-file-mac-key', true));
326            }
327        } else {
328            $private .= Random::string(16 - (strlen($private) & 15));
329            $source .= Strings::packSSH2('s', $private);
330            $crypto = new AES('cbc');
331
332            switch ($version) {
333                case 3:
334                    $salt = Random::string(16);
335                    $key .= "Key-Derivation: Argon2id\r\n";
336                    $key .= "Argon2-Memory: 8192\r\n";
337                    $key .= "Argon2-Passes: 13\r\n";
338                    $key .= "Argon2-Parallelism: 1\r\n";
339                    $key .= "Argon2-Salt: " . Hex::encode($salt) . "\r\n";
340                    extract(self::generateV3Key($password, 'Argon2id', 8192, 13, $salt));
341
342                    $hash = new Hash('sha256');
343                    $hash->setKey($hashkey);
344
345                    break;
346                case 2:
347                    $symkey = self::generateV2Key($password, 32);
348                    $symiv = str_repeat("\0", $crypto->getBlockLength() >> 3);
349                    $hashkey = 'putty-private-key-file-mac-key' . $password;
350
351                    $hash = new Hash('sha1');
352                    $hash->setKey(sha1($hashkey, true));
353            }
354
355            $crypto->setKey($symkey);
356            $crypto->setIV($symiv);
357            $crypto->disablePadding();
358            $private = $crypto->encrypt($private);
359            $mac = $hash->hash($source);
360        }
361
362        $private = Base64::encode($private);
363        $key .= 'Private-Lines: ' . ((strlen($private) + 63) >> 6) . "\r\n";
364        $key .= chunk_split($private, 64);
365        $key .= 'Private-MAC: ' . Hex::encode($hash->hash($source)) . "\r\n";
366
367        return $key;
368    }
369
370    /**
371     * Wrap a public key appropriately
372     *
373     * This is basically the format described in RFC 4716 (https://tools.ietf.org/html/rfc4716)
374     *
375     * @access private
376     * @param string $key
377     * @param string $type
378     * @return string
379     */
380    protected static function wrapPublicKey($key, $type)
381    {
382        $key = pack('Na*a*', strlen($type), $type, $key);
383        $key = "---- BEGIN SSH2 PUBLIC KEY ----\r\n" .
384               'Comment: "' . str_replace(['\\', '"'], ['\\\\', '\"'], self::$comment) . "\"\r\n" .
385               chunk_split(Base64::encode($key), 64) .
386               '---- END SSH2 PUBLIC KEY ----';
387        return $key;
388    }
389}
390