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