@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator

Correctly identify more SSH private key problems as "formatting" or "passphrase" related

Summary:
Ref T13454. Fixes T13006. When a user provide us with an SSH private key and (possibly) a passphrase:

# Try to verify that they're correct by extracting the public key.
# If that fails, try to figure out why it didn't work.

Our success in step (2) will vary depending on what the problem is, and we may end up falling through to a very generic error, but the outcome should generally be better than the old approach.

Previously, we had a very unsophisticated test for the text "ENCRYPTED" in the key body and questionable handling of the results: for example, providing a passphrase when a key did not require one did not raise an error.

Test Plan:
Created and edited credentials with:

- Valid, passphrase-free keys.
- Valid, passphrased keys with the right passphrase.
- Valid, passphrase-free keys with a passphrase ("surplus passphrase" error).
- Valid, passphrased keys with no passphrase ("missing passphrase" error).
- Valid, passphrased keys with an invalid passphrase ("invalid passphrase" error).
- Invalid keys ("format" error).

The precision of these errors will vary depending on how helpful "ssh-keygen" is.

Maniphest Tasks: T13454, T13006

Differential Revision: https://secure.phabricator.com/D20905

+323 -81
+16
src/__phutil_library_map__.php
··· 2439 2439 'PhabricatorAuthSSHKeyTransaction' => 'applications/auth/storage/PhabricatorAuthSSHKeyTransaction.php', 2440 2440 'PhabricatorAuthSSHKeyTransactionQuery' => 'applications/auth/query/PhabricatorAuthSSHKeyTransactionQuery.php', 2441 2441 'PhabricatorAuthSSHKeyViewController' => 'applications/auth/controller/PhabricatorAuthSSHKeyViewController.php', 2442 + 'PhabricatorAuthSSHPrivateKey' => 'applications/auth/sshkey/PhabricatorAuthSSHPrivateKey.php', 2443 + 'PhabricatorAuthSSHPrivateKeyException' => 'applications/auth/exception/privatekey/PhabricatorAuthSSHPrivateKeyException.php', 2444 + 'PhabricatorAuthSSHPrivateKeyFormatException' => 'applications/auth/exception/privatekey/PhabricatorAuthSSHPrivateKeyFormatException.php', 2445 + 'PhabricatorAuthSSHPrivateKeyIncorrectPassphraseException' => 'applications/auth/exception/privatekey/PhabricatorAuthSSHPrivateKeyIncorrectPassphraseException.php', 2446 + 'PhabricatorAuthSSHPrivateKeyMissingPassphraseException' => 'applications/auth/exception/privatekey/PhabricatorAuthSSHPrivateKeyMissingPassphraseException.php', 2447 + 'PhabricatorAuthSSHPrivateKeyPassphraseException' => 'applications/auth/exception/privatekey/PhabricatorAuthSSHPrivateKeyPassphraseException.php', 2448 + 'PhabricatorAuthSSHPrivateKeySurplusPassphraseException' => 'applications/auth/exception/privatekey/PhabricatorAuthSSHPrivateKeySurplusPassphraseException.php', 2449 + 'PhabricatorAuthSSHPrivateKeyUnknownException' => 'applications/auth/exception/privatekey/PhabricatorAuthSSHPrivateKeyUnknownException.php', 2442 2450 'PhabricatorAuthSSHPublicKey' => 'applications/auth/sshkey/PhabricatorAuthSSHPublicKey.php', 2443 2451 'PhabricatorAuthSSHRevoker' => 'applications/auth/revoker/PhabricatorAuthSSHRevoker.php', 2444 2452 'PhabricatorAuthSession' => 'applications/auth/storage/PhabricatorAuthSession.php', ··· 8679 8687 'PhabricatorAuthSSHKeyTransaction' => 'PhabricatorApplicationTransaction', 8680 8688 'PhabricatorAuthSSHKeyTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 8681 8689 'PhabricatorAuthSSHKeyViewController' => 'PhabricatorAuthSSHKeyController', 8690 + 'PhabricatorAuthSSHPrivateKey' => 'Phobject', 8691 + 'PhabricatorAuthSSHPrivateKeyException' => 'Exception', 8692 + 'PhabricatorAuthSSHPrivateKeyFormatException' => 'PhabricatorAuthSSHPrivateKeyException', 8693 + 'PhabricatorAuthSSHPrivateKeyIncorrectPassphraseException' => 'PhabricatorAuthSSHPrivateKeyPassphraseException', 8694 + 'PhabricatorAuthSSHPrivateKeyMissingPassphraseException' => 'PhabricatorAuthSSHPrivateKeyPassphraseException', 8695 + 'PhabricatorAuthSSHPrivateKeyPassphraseException' => 'PhabricatorAuthSSHPrivateKeyException', 8696 + 'PhabricatorAuthSSHPrivateKeySurplusPassphraseException' => 'PhabricatorAuthSSHPrivateKeyPassphraseException', 8697 + 'PhabricatorAuthSSHPrivateKeyUnknownException' => 'PhabricatorAuthSSHPrivateKeyException', 8682 8698 'PhabricatorAuthSSHPublicKey' => 'Phobject', 8683 8699 'PhabricatorAuthSSHRevoker' => 'PhabricatorAuthRevoker', 8684 8700 'PhabricatorAuthSession' => array(
+9
src/applications/auth/exception/privatekey/PhabricatorAuthSSHPrivateKeyException.php
··· 1 + <?php 2 + 3 + abstract class PhabricatorAuthSSHPrivateKeyException 4 + extends Exception { 5 + 6 + abstract public function isFormatException(); 7 + abstract public function isPassphraseException(); 8 + 9 + }
+14
src/applications/auth/exception/privatekey/PhabricatorAuthSSHPrivateKeyFormatException.php
··· 1 + <?php 2 + 3 + final class PhabricatorAuthSSHPrivateKeyFormatException 4 + extends PhabricatorAuthSSHPrivateKeyException { 5 + 6 + public function isFormatException() { 7 + return true; 8 + } 9 + 10 + public function isPassphraseException() { 11 + return false; 12 + } 13 + 14 + }
+4
src/applications/auth/exception/privatekey/PhabricatorAuthSSHPrivateKeyIncorrectPassphraseException.php
··· 1 + <?php 2 + 3 + final class PhabricatorAuthSSHPrivateKeyIncorrectPassphraseException 4 + extends PhabricatorAuthSSHPrivateKeyPassphraseException {}
+4
src/applications/auth/exception/privatekey/PhabricatorAuthSSHPrivateKeyMissingPassphraseException.php
··· 1 + <?php 2 + 3 + final class PhabricatorAuthSSHPrivateKeyMissingPassphraseException 4 + extends PhabricatorAuthSSHPrivateKeyPassphraseException {}
+14
src/applications/auth/exception/privatekey/PhabricatorAuthSSHPrivateKeyPassphraseException.php
··· 1 + <?php 2 + 3 + abstract class PhabricatorAuthSSHPrivateKeyPassphraseException 4 + extends PhabricatorAuthSSHPrivateKeyException { 5 + 6 + final public function isFormatException() { 7 + return false; 8 + } 9 + 10 + final public function isPassphraseException() { 11 + return true; 12 + } 13 + 14 + }
+4
src/applications/auth/exception/privatekey/PhabricatorAuthSSHPrivateKeySurplusPassphraseException.php
··· 1 + <?php 2 + 3 + final class PhabricatorAuthSSHPrivateKeySurplusPassphraseException 4 + extends PhabricatorAuthSSHPrivateKeyPassphraseException {}
+14
src/applications/auth/exception/privatekey/PhabricatorAuthSSHPrivateKeyUnknownException.php
··· 1 + <?php 2 + 3 + final class PhabricatorAuthSSHPrivateKeyUnknownException 4 + extends PhabricatorAuthSSHPrivateKeyException { 5 + 6 + public function isFormatException() { 7 + return true; 8 + } 9 + 10 + public function isPassphraseException() { 11 + return true; 12 + } 13 + 14 + }
+210
src/applications/auth/sshkey/PhabricatorAuthSSHPrivateKey.php
··· 1 + <?php 2 + 3 + /** 4 + * Data structure representing a raw private key. 5 + */ 6 + final class PhabricatorAuthSSHPrivateKey extends Phobject { 7 + 8 + private $body; 9 + private $passphrase; 10 + 11 + private function __construct() { 12 + // <internal> 13 + } 14 + 15 + public function setPassphrase(PhutilOpaqueEnvelope $passphrase) { 16 + $this->passphrase = $passphrase; 17 + return $this; 18 + } 19 + 20 + public function getPassphrase() { 21 + return $this->passphrase; 22 + } 23 + 24 + public static function newFromRawKey(PhutilOpaqueEnvelope $entire_key) { 25 + $key = new self(); 26 + 27 + $key->body = $entire_key; 28 + 29 + return $key; 30 + } 31 + 32 + public function getKeyBody() { 33 + return $this->body; 34 + } 35 + 36 + public function newBarePrivateKey() { 37 + if (!Filesystem::binaryExists('ssh-keygen')) { 38 + throw new Exception( 39 + pht( 40 + 'Analyzing or decrypting SSH keys requires the "ssh-keygen" binary, '. 41 + 'but it is not available in "$PATH". Make it available to work with '. 42 + 'SSH private keys.')); 43 + } 44 + 45 + $old_body = $this->body; 46 + 47 + // Some versions of "ssh-keygen" are sensitive to trailing whitespace for 48 + // some keys. Trim any trailing whitespace and replace it with a single 49 + // newline. 50 + $raw_body = $old_body->openEnvelope(); 51 + $raw_body = rtrim($raw_body)."\n"; 52 + $old_body = new PhutilOpaqueEnvelope($raw_body); 53 + 54 + $tmp = $this->newTemporaryPrivateKeyFile($old_body); 55 + 56 + // See T13454 for discussion of why this is so awkward. In broad strokes, 57 + // we don't have a straightforward way to distinguish between keys with an 58 + // invalid format and keys with a passphrase which we don't know. 59 + 60 + // First, try to extract the public key from the file using the (possibly 61 + // empty) passphrase we were given. If everything is in good shape, this 62 + // should work. 63 + 64 + $passphrase = $this->getPassphrase(); 65 + if ($passphrase) { 66 + list($err, $stdout, $stderr) = exec_manual( 67 + 'ssh-keygen -y -P %P -f %R', 68 + $passphrase, 69 + $tmp); 70 + } else { 71 + list($err, $stdout, $stderr) = exec_manual( 72 + 'ssh-keygen -y -P %s -f %R', 73 + '', 74 + $tmp); 75 + } 76 + 77 + // If that worked, the key is good and the (possibly empty) passphrase is 78 + // correct. Strip the passphrase if we have one, then return the bare key. 79 + 80 + if (!$err) { 81 + if ($passphrase) { 82 + execx( 83 + 'ssh-keygen -y -P %P -N %s -f %R', 84 + $passphrase, 85 + '', 86 + $tmp); 87 + 88 + $new_body = new PhutilOpaqueEnvelope(Filesystem::readFile($tmp)); 89 + unset($tmp); 90 + } else { 91 + $new_body = $old_body; 92 + } 93 + 94 + return self::newFromRawKey($new_body); 95 + } 96 + 97 + // We were not able to extract the public key. Try to figure out why. The 98 + // reasons we expect are: 99 + // 100 + // - We were given a passphrase, but the key has no passphrase. 101 + // - We were given a passphrase, but the passphrase is wrong. 102 + // - We were not given a passphrase, but the key has a passphrase. 103 + // - The key format is invalid. 104 + // 105 + // Our ability to separate these cases varies a lot, particularly because 106 + // some versions of "ssh-keygen" return very similar diagnostic messages 107 + // for any error condition. Try our best. 108 + 109 + if ($passphrase) { 110 + // First, test for "we were given a passphrase, but the key has no 111 + // passphrase", since this is a conclusive test. 112 + list($err) = exec_manual( 113 + 'ssh-keygen -y -P %s -f %R', 114 + '', 115 + $tmp); 116 + if (!$err) { 117 + throw new PhabricatorAuthSSHPrivateKeySurplusPassphraseException( 118 + pht( 119 + 'A passphrase was provided for this private key, but it does '. 120 + 'not require a passphrase. Check that you supplied the correct '. 121 + 'key, or omit the passphrase.')); 122 + } 123 + } 124 + 125 + // We're out of conclusive tests, so try to guess why the error occurred. 126 + // In some versions of "ssh-keygen", we get a usable diagnostic message. In 127 + // other versions, not so much. 128 + 129 + $reason_format = 'format'; 130 + $reason_passphrase = 'passphrase'; 131 + $reason_unknown = 'unknown'; 132 + 133 + $patterns = array( 134 + // macOS 10.14.6 135 + '/incorrect passphrase supplied to decrypt private key/' 136 + => $reason_passphrase, 137 + 138 + // macOS 10.14.6 139 + '/invalid format/' => $reason_format, 140 + 141 + // Ubuntu 14 142 + '/load failed/' => $reason_unknown, 143 + ); 144 + 145 + $reason = 'unknown'; 146 + foreach ($patterns as $pattern => $pattern_reason) { 147 + $ok = preg_match($pattern, $stderr); 148 + 149 + if ($ok === false) { 150 + throw new Exception( 151 + pht( 152 + 'Pattern "%s" is not valid.', 153 + $pattern)); 154 + } 155 + 156 + if ($ok) { 157 + $reason = $pattern_reason; 158 + break; 159 + } 160 + } 161 + 162 + if ($reason === $reason_format) { 163 + throw new PhabricatorAuthSSHPrivateKeyFormatException( 164 + pht( 165 + 'This private key is not formatted correctly. Check that you '. 166 + 'have provided the complete text of a valid private key.')); 167 + } 168 + 169 + if ($reason === $reason_passphrase) { 170 + if ($passphrase) { 171 + throw new PhabricatorAuthSSHPrivateKeyIncorrectPassphraseException( 172 + pht( 173 + 'This private key requires a passphrase, but the wrong '. 174 + 'passphrase was provided. Check that you supplied the correct '. 175 + 'key and passphrase.')); 176 + } else { 177 + throw new PhabricatorAuthSSHPrivateKeyIncorrectPassphraseException( 178 + pht( 179 + 'This private key requires a passphrase, but no passphrase was '. 180 + 'provided. Check that you supplied the correct key, or provide '. 181 + 'the passphrase.')); 182 + } 183 + } 184 + 185 + if ($passphrase) { 186 + throw new PhabricatorAuthSSHPrivateKeyUnknownException( 187 + pht( 188 + 'This private key could not be opened with the provided passphrase. '. 189 + 'This might mean that the passphrase is wrong or that the key is '. 190 + 'not formatted correctly. Check that you have supplied the '. 191 + 'complete text of a valid private key and the correct passphrase.')); 192 + } else { 193 + throw new PhabricatorAuthSSHPrivateKeyUnknownException( 194 + pht( 195 + 'This private key could not be opened. This might mean that the '. 196 + 'key requires a passphrase, or might mean that the key is not '. 197 + 'formatted correctly. Check that you have supplied the complete '. 198 + 'text of a valid private key and the correct passphrase.')); 199 + } 200 + } 201 + 202 + private function newTemporaryPrivateKeyFile(PhutilOpaqueEnvelope $key_body) { 203 + $tmp = new TempFile(); 204 + 205 + Filesystem::writeFile($tmp, $key_body->openEnvelope()); 206 + 207 + return $tmp; 208 + } 209 + 210 + }
+34 -16
src/applications/passphrase/controller/PassphraseCredentialEditController.php
··· 80 80 $validation_exception = null; 81 81 $errors = array(); 82 82 $e_password = null; 83 + $e_secret = null; 83 84 if ($request->isFormPost()) { 84 85 85 86 $v_name = $request->getStr('name'); ··· 97 98 $env_secret = new PhutilOpaqueEnvelope($v_secret); 98 99 $env_password = new PhutilOpaqueEnvelope($v_password); 99 100 100 - if ($type->requiresPassword($env_secret)) { 101 + $has_secret = !preg_match('/^('.$bullet.')+$/', trim($v_decrypt)); 102 + 103 + // Validate and repair SSH private keys, and apply passwords if they 104 + // are provided. See T13454 for discussion. 105 + 106 + // This should eventually be refactored to be modular rather than a 107 + // hard-coded set of behaviors here in the Controller, but this is 108 + // likely a fairly extensive change. 109 + 110 + $is_ssh = ($type instanceof PassphraseSSHPrivateKeyTextCredentialType); 111 + 112 + if ($is_ssh && $has_secret) { 113 + $old_object = PhabricatorAuthSSHPrivateKey::newFromRawKey($env_secret); 114 + 101 115 if (strlen($v_password)) { 102 - $v_decrypt = $type->decryptSecret($env_secret, $env_password); 103 - if ($v_decrypt === null) { 104 - $e_password = pht('Incorrect'); 105 - $errors[] = pht( 106 - 'This key requires a password, but the password you provided '. 107 - 'is incorrect.'); 108 - } else { 109 - $v_decrypt = $v_decrypt->openEnvelope(); 116 + $old_object->setPassphrase($env_password); 117 + } 118 + 119 + try { 120 + $new_object = $old_object->newBarePrivateKey(); 121 + $v_decrypt = $new_object->getKeyBody()->openEnvelope(); 122 + } catch (PhabricatorAuthSSHPrivateKeyException $ex) { 123 + $errors[] = $ex->getMessage(); 124 + 125 + if ($ex->isFormatException()) { 126 + $e_secret = pht('Invalid'); 110 127 } 111 - } else { 112 - $e_password = pht('Required'); 113 - $errors[] = pht( 114 - 'This key requires a password. You must provide the password '. 115 - 'for the key.'); 128 + if ($ex->isPassphraseException()) { 129 + $e_password = pht('Invalid'); 130 + } 116 131 } 117 132 } 118 133 ··· 166 181 ->setTransactionType($type_username) 167 182 ->setNewValue($v_username); 168 183 } 184 + 169 185 // If some value other than a sequence of bullets was provided for 170 186 // the credential, update it. In particular, note that we are 171 187 // explicitly allowing empty secrets: one use case is HTTP auth where 172 188 // the username is a secret token which covers both identity and 173 189 // authentication. 174 190 175 - if (!preg_match('/^('.$bullet.')+$/', trim($v_decrypt))) { 191 + if ($has_secret) { 176 192 // If the credential was previously destroyed, restore it when it is 177 193 // edited if a secret is provided. 178 194 $xactions[] = id(new PassphraseCredentialTransaction()) ··· 182 198 $new_secret = id(new PassphraseSecret()) 183 199 ->setSecretData($v_decrypt) 184 200 ->save(); 201 + 185 202 $xactions[] = id(new PassphraseCredentialTransaction()) 186 203 ->setTransactionType($type_secret_id) 187 204 ->setNewValue($new_secret->getID()); ··· 287 304 ->setName('secret') 288 305 ->setLabel($type->getSecretLabel()) 289 306 ->setDisabled($credential_is_locked) 290 - ->setValue($v_secret)); 307 + ->setValue($v_secret) 308 + ->setError($e_secret)); 291 309 292 310 if ($type->shouldShowPasswordField()) { 293 311 $form->appendChild(
-29
src/applications/passphrase/credentialtype/PassphraseCredentialType.php
··· 102 102 return pht('Password'); 103 103 } 104 104 105 - 106 - /** 107 - * Return true if the provided credential requires a password to decrypt. 108 - * 109 - * @param PhutilOpaqueEnvelope Credential secret value. 110 - * @return bool True if the credential needs a password. 111 - * 112 - * @task password 113 - */ 114 - public function requiresPassword(PhutilOpaqueEnvelope $secret) { 115 - return false; 116 - } 117 - 118 - 119 - /** 120 - * Return the decrypted credential secret, or `null` if the password does 121 - * not decrypt the credential. 122 - * 123 - * @param PhutilOpaqueEnvelope Credential secret value. 124 - * @param PhutilOpaqueEnvelope Credential password. 125 - * @return 126 - * @task password 127 - */ 128 - public function decryptSecret( 129 - PhutilOpaqueEnvelope $secret, 130 - PhutilOpaqueEnvelope $password) { 131 - return $secret; 132 - } 133 - 134 105 public function shouldRequireUsername() { 135 106 return true; 136 107 }
-36
src/applications/passphrase/credentialtype/PassphraseSSHPrivateKeyTextCredentialType.php
··· 29 29 return pht('Password for Key'); 30 30 } 31 31 32 - public function requiresPassword(PhutilOpaqueEnvelope $secret) { 33 - // According to the internet, this is the canonical test for an SSH private 34 - // key with a password. 35 - return preg_match('/ENCRYPTED/', $secret->openEnvelope()); 36 - } 37 - 38 - public function decryptSecret( 39 - PhutilOpaqueEnvelope $secret, 40 - PhutilOpaqueEnvelope $password) { 41 - 42 - $tmp = new TempFile(); 43 - Filesystem::writeFile($tmp, $secret->openEnvelope()); 44 - 45 - if (!Filesystem::binaryExists('ssh-keygen')) { 46 - throw new Exception( 47 - pht( 48 - 'Decrypting SSH keys requires the `%s` binary, but it '. 49 - 'is not available in %s. Either make it available or strip the '. 50 - 'password from this SSH key manually before uploading it.', 51 - 'ssh-keygen', 52 - '$PATH')); 53 - } 54 - 55 - list($err, $stdout, $stderr) = exec_manual( 56 - 'ssh-keygen -p -P %P -N %s -f %s', 57 - $password, 58 - '', 59 - (string)$tmp); 60 - 61 - if ($err) { 62 - return null; 63 - } else { 64 - return new PhutilOpaqueEnvelope(Filesystem::readFile($tmp)); 65 - } 66 - } 67 - 68 32 }