1<?php
2
3/**
4 * OpenSSH Key Handler
5 *
6 * PHP version 5
7 *
8 * Place in $HOME/.ssh/authorized_keys
9 *
10 * @category  Crypt
11 * @package   Common
12 * @author    Jim Wigginton <terrafrost@php.net>
13 * @copyright 2015 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 phpseclib3\Common\Functions\Strings;
22use phpseclib3\Crypt\Random;
23use phpseclib3\Exception\UnsupportedFormatException;
24
25/**
26 * OpenSSH Formatted RSA Key Handler
27 *
28 * @package Common
29 * @author  Jim Wigginton <terrafrost@php.net>
30 * @access  public
31 */
32abstract class OpenSSH
33{
34    /**
35     * Default comment
36     *
37     * @var string
38     * @access private
39     */
40    protected static $comment = 'phpseclib-generated-key';
41
42    /**
43     * Binary key flag
44     *
45     * @var bool
46     * @access private
47     */
48    protected static $binary = false;
49
50    /**
51     * Sets the default comment
52     *
53     * @access public
54     * @param string $comment
55     */
56    public static function setComment($comment)
57    {
58        self::$comment = str_replace(["\r", "\n"], '', $comment);
59    }
60
61    /**
62     * Break a public or private key down into its constituent components
63     *
64     * $type can be either ssh-dss or ssh-rsa
65     *
66     * @access public
67     * @param string $key
68     * @param string $password
69     * @return array
70     */
71    public static function load($key, $password = '')
72    {
73        if (!Strings::is_stringable($key)) {
74            throw new \UnexpectedValueException('Key should be a string - not a ' . gettype($key));
75        }
76
77        // key format is described here:
78        // https://cvsweb.openbsd.org/cgi-bin/cvsweb/src/usr.bin/ssh/PROTOCOL.key?annotate=HEAD
79
80        if (strpos($key, 'BEGIN OPENSSH PRIVATE KEY') !== false) {
81            $key = preg_replace('#(?:^-.*?-[\r\n]*$)|\s#ms', '', $key);
82            $key = Base64::decode($key);
83            $magic = Strings::shift($key, 15);
84            if ($magic != "openssh-key-v1\0") {
85                throw new \RuntimeException('Expected openssh-key-v1');
86            }
87            list($ciphername, $kdfname, $kdfoptions, $numKeys) = Strings::unpackSSH2('sssN', $key);
88            if ($numKeys != 1) {
89                // if we wanted to support multiple keys we could update PublicKeyLoader to preview what the # of keys
90                // would be; it'd then call Common\Keys\OpenSSH.php::load() and get the paddedKey. it'd then pass
91                // that to the appropriate key loading parser $numKey times or something
92                throw new \RuntimeException('Although the OpenSSH private key format supports multiple keys phpseclib does not');
93            }
94            if (strlen($kdfoptions) || $kdfname != 'none' || $ciphername != 'none') {
95                /*
96                  OpenSSH private keys use a customized version of bcrypt. specifically, instead of encrypting
97                  OrpheanBeholderScryDoubt 64 times OpenSSH's bcrypt variant encrypts
98                  OxychromaticBlowfishSwatDynamite 64 times. so we can't use crypt().
99
100                  bcrypt is basically Blowfish with an altered key expansion. whereas Blowfish just runs the
101                  key through the key expansion bcrypt interleaves the key expansion with the salt and
102                  password. this renders openssl / mcrypt unusuable. this forces us to use a pure-PHP implementation
103                  of bcrypt. the problem with that is that pure-PHP is too slow to be practically useful.
104
105                  in addition to encrypting a different string 64 times the OpenSSH implementation also performs bcrypt
106                  from scratch $rounds times. calling crypt() 64x with bcrypt takes 0.7s. PHP is going to be naturally
107                  slower. pure-PHP is 215x slower than OpenSSL for AES and pure-PHP is 43x slower for bcrypt.
108                  43 * 0.7 = 30s. no one wants to wait 30s to load a private key.
109
110                  another way to think about this..  according to wikipedia's article on Blowfish,
111                  "Each new key requires pre-processing equivalent to encrypting about 4 kilobytes of text".
112                  key expansion is done (9+64*2)*160 times. multiply that by 4 and it turns out that Blowfish,
113                  OpenSSH style, is the equivalent of encrypting ~80mb of text.
114
115                  more supporting evidence: sodium_compat does not implement Argon2 (another password hashing
116                  algorithm) because "It's not feasible to polyfill scrypt or Argon2 into PHP and get reasonable
117                  performance. Users would feel motivated to select parameters that downgrade security to avoid
118                  denial of service (DoS) attacks. The only winning move is not to play"
119                    -- https://github.com/paragonie/sodium_compat/blob/master/README.md
120                */
121                throw new \RuntimeException('Encrypted OpenSSH private keys are not supported');
122                //list($salt, $rounds) = Strings::unpackSSH2('sN', $kdfoptions);
123            }
124
125            list($publicKey, $paddedKey) = Strings::unpackSSH2('ss', $key);
126            list($type) = Strings::unpackSSH2('s', $publicKey);
127            list($checkint1, $checkint2) = Strings::unpackSSH2('NN', $paddedKey);
128            // any leftover bytes in $paddedKey are for padding? but they should be sequential bytes. eg. 1, 2, 3, etc.
129            if ($checkint1 != $checkint2) {
130                throw new \RuntimeException('The two checkints do not match');
131            }
132            self::checkType($type);
133
134            return compact('type', 'publicKey', 'paddedKey');
135        }
136
137        $parts = explode(' ', $key, 3);
138
139        if (!isset($parts[1])) {
140            $key = base64_decode($parts[0]);
141            $comment = isset($parts[1]) ? $parts[1] : false;
142        } else {
143            $asciiType = $parts[0];
144            self::checkType($parts[0]);
145            $key = base64_decode($parts[1]);
146            $comment = isset($parts[2]) ? $parts[2] : false;
147        }
148        if ($key === false) {
149            throw new \UnexpectedValueException('Key should be a string - not a ' . gettype($key));
150        }
151
152        list($type) = Strings::unpackSSH2('s', $key);
153        self::checkType($type);
154        if (isset($asciiType) && $asciiType != $type) {
155            throw new \RuntimeException('Two different types of keys are claimed: ' . $asciiType . ' and ' . $type);
156        }
157        if (strlen($key) <= 4) {
158            throw new \UnexpectedValueException('Key appears to be malformed');
159        }
160
161        $publicKey = $key;
162
163        return compact('type', 'publicKey', 'comment');
164    }
165
166    /**
167     * Toggle between binary and printable keys
168     *
169     * Printable keys are what are generated by default. These are the ones that go in
170     * $HOME/.ssh/authorized_key.
171     *
172     * @access public
173     * @param bool $enabled
174     */
175    public static function setBinaryOutput($enabled)
176    {
177        self::$binary = $enabled;
178    }
179
180    /**
181     * Checks to see if the type is valid
182     *
183     * @access private
184     * @param string $candidate
185     */
186    private static function checkType($candidate)
187    {
188        if (!in_array($candidate, static::$types)) {
189            throw new \RuntimeException("The key type ($candidate) is not equal to: " . implode(',', static::$types));
190        }
191    }
192
193    /**
194     * Wrap a private key appropriately
195     *
196     * @access public
197     * @param string $publicKey
198     * @param string $privateKey
199     * @param string $password
200     * @param array $options
201     * @return string
202     */
203    protected static function wrapPrivateKey($publicKey, $privateKey, $password, $options)
204    {
205        if (!empty($password) && is_string($password)) {
206            throw new UnsupportedFormatException('Encrypted OpenSSH private keys are not supported');
207        }
208
209        list(, $checkint) = unpack('N', Random::string(4));
210
211        $comment = isset($options['comment']) ? $options['comment'] : self::$comment;
212        $paddedKey = Strings::packSSH2('NN', $checkint, $checkint) .
213                     $privateKey .
214                     Strings::packSSH2('s', $comment);
215
216        /*
217           from http://tools.ietf.org/html/rfc4253#section-6 :
218
219           Note that the length of the concatenation of 'packet_length',
220           'padding_length', 'payload', and 'random padding' MUST be a multiple
221           of the cipher block size or 8, whichever is larger.
222         */
223        $paddingLength = (7 * strlen($paddedKey)) % 8;
224        for ($i = 1; $i <= $paddingLength; $i++) {
225            $paddedKey .= chr($i);
226        }
227        $key = Strings::packSSH2('sssNss', 'none', 'none', '', 1, $publicKey, $paddedKey);
228        $key = "openssh-key-v1\0$key";
229
230        return "-----BEGIN OPENSSH PRIVATE KEY-----\n" .
231               chunk_split(Base64::encode($key), 70, "\n") .
232               "-----END OPENSSH PRIVATE KEY-----\n";
233    }
234}
235