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