Authentication using OpenKeystore SSH API (#486)

* Implemented OpenKeystore SSH API as a new authentication option

* Fix formatting problems

Signed-off-by: Harsh Shandilya <msfjarvis@gmail.com>

* Addressed review comments.

Removed leftover debugging code.
Wrapped excessively long lines.
Added missing new parameter to Javadoc.

* Merge remote-tracking branch 'upstream/master' into gpg-ssh-key
This commit is contained in:
vexofp 2019-04-05 18:14:38 -04:00 committed by Mohamed Zenadi
parent 94bf103b33
commit f272e4dde2
6 changed files with 493 additions and 62 deletions

View file

@ -84,6 +84,7 @@ dependencies {
implementation("com.jayway.android.robotium:robotium-solo:5.6.3")
implementation(kotlin("stdlib-jdk8", KotlinCompilerVersion.VERSION))
implementation("androidx.constraintlayout:constraintlayout:1.1.3")
implementation("org.sufficientlysecure:sshauthentication-api:1.0")
// Testing-only dependencies
androidTestImplementation("junit:junit:4.12")

View file

@ -17,11 +17,15 @@ import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.Spinner;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import com.zeapo.pwdstore.R;
import com.zeapo.pwdstore.UserPreference;
import com.zeapo.pwdstore.git.config.SshApiSessionFactory;
import com.zeapo.pwdstore.utils.PasswordRepository;
import org.apache.commons.io.FileUtils;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.RebaseCommand;
@ -53,6 +57,8 @@ public class GitActivity extends AppCompatActivity {
private File localDir;
private String hostname;
private SharedPreferences settings;
private SshApiSessionFactory.IdentityBuilder identityBuilder;
private SshApiSessionFactory.ApiIdentity identity;
@Override
protected void onCreate(Bundle savedInstanceState) {
@ -117,8 +123,10 @@ public class GitActivity extends AppCompatActivity {
connection_mode_spinner.setEnabled(true);
// however, if we have some saved that, that's more important!
if (connectionMode.equals("ssh-key")) {
if (connectionMode.equalsIgnoreCase("ssh-key")) {
connection_mode_spinner.setSelection(0);
} else if (connectionMode.equalsIgnoreCase("OpenKeychain")) {
connection_mode_spinner.setSelection(2);
} else {
connection_mode_spinner.setSelection(1);
}
@ -370,6 +378,16 @@ public class GitActivity extends AppCompatActivity {
updateURI();
}
@Override
protected void onDestroy() {
// Do not leak the service connection
if (identityBuilder != null) {
identityBuilder.close();
identityBuilder = null;
}
super.onDestroy();
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
// Inflate the menu; this adds items to the action bar if it is present.
@ -556,16 +574,7 @@ public class GitActivity extends AppCompatActivity {
(dialog, id) -> {
try {
FileUtils.deleteDirectory(localDir);
try {
new CloneOperation(localDir, activity)
.setCommand(hostname)
.executeAfterAuthentication(connectionMode, settings.getString("git_remote_username", "git"), new File(getFilesDir() + "/.ssh_key"));
} catch (Exception e) {
//This is what happens when jgit fails :(
//TODO Handle the diffent cases of exceptions
e.printStackTrace();
new AlertDialog.Builder(GitActivity.this).setMessage(e.getMessage()).show();
}
launchGitOperation(REQUEST_CLONE);
} catch (IOException e) {
//TODO Handle the exception correctly if we are unable to delete the directory...
e.printStackTrace();
@ -590,15 +599,13 @@ public class GitActivity extends AppCompatActivity {
new AlertDialog.Builder(GitActivity.this).setMessage(e.getMessage()).show();
}
}
new CloneOperation(localDir, activity)
.setCommand(hostname)
.executeAfterAuthentication(connectionMode, settings.getString("git_remote_username", "git"), new File(getFilesDir() + "/.ssh_key"));
} catch (Exception e) {
//This is what happens when jgit fails :(
//TODO Handle the diffent cases of exceptions
e.printStackTrace();
new AlertDialog.Builder(this).setMessage(e.getMessage()).show();
}
launchGitOperation(REQUEST_CLONE);
}
}
@ -627,47 +634,45 @@ public class GitActivity extends AppCompatActivity {
else {
// check that the remote origin is here, else add it
PasswordRepository.addRemote("origin", hostname, false);
GitOperation op;
switch (operation) {
case REQUEST_PULL:
op = new PullOperation(localDir, activity).setCommand();
break;
case REQUEST_PUSH:
op = new PushOperation(localDir, activity).setCommand();
break;
case REQUEST_SYNC:
op = new SyncOperation(localDir, activity).setCommands();
break;
default:
Log.e(TAG, "Sync operation not recognized : " + operation);
return;
}
try {
op.executeAfterAuthentication(connectionMode, settings.getString("git_remote_username", "git"), new File(getFilesDir() + "/.ssh_key"));
} catch (Exception e) {
e.printStackTrace();
}
launchGitOperation(operation);
}
}
protected void onActivityResult(int requestCode, int resultCode,
Intent data) {
if (resultCode == RESULT_CANCELED) {
setResult(RESULT_CANCELED);
finish();
return;
}
/**
* Attempt to launch the requested GIT operation. Depending on the configured auth, it may not
* be possible to launch the operation immediately. In that case, this function may launch an
* intermediate activity instead, which will gather necessary information and post it back via
* onActivityResult, which will then re-call this function. This may happen multiple times,
* until either an error is encountered or the operation is successfully launched.
*
* @param operation The type of GIT operation to launch
*/
protected void launchGitOperation(int operation) {
GitOperation op;
if (resultCode == RESULT_OK) {
GitOperation op;
try {
switch (requestCode) {
case REQUEST_CLONE:
setResult(RESULT_OK);
finish();
// Before launching the operation with OpenKeychain auth, we need to issue several requests
// to the OpenKeychain API. IdentityBuild will take care of launching the relevant intents,
// we just need to keep calling it until it returns a completed ApiIdentity.
if (connectionMode.equalsIgnoreCase("OpenKeychain") && identity == null) {
// Lazy initialization of the IdentityBuilder
if (identityBuilder == null) {
identityBuilder = new SshApiSessionFactory.IdentityBuilder(this);
}
// Try to get an ApiIdentity and bail if one is not ready yet. The builder will ensure
// that onActivityResult is called with operation again, which will re-invoke us here
identity = identityBuilder.tryBuild(operation);
if (identity == null)
return;
}
switch (operation) {
case REQUEST_CLONE:
op = new CloneOperation(localDir, activity).setCommand(hostname);
break;
case REQUEST_PULL:
op = new PullOperation(localDir, activity).setCommand();
break;
@ -676,22 +681,57 @@ public class GitActivity extends AppCompatActivity {
op = new PushOperation(localDir, activity).setCommand();
break;
case REQUEST_SYNC:
op = new SyncOperation(localDir, activity).setCommands();
break;
case GitOperation.GET_SSH_KEY_FROM_CLONE:
op = new CloneOperation(localDir, activity).setCommand(hostname);
break;
case SshApiSessionFactory.POST_SIGNATURE:
return;
default:
Log.e(TAG, "Operation not recognized : " + resultCode);
Log.e(TAG, "Operation not recognized : " + operation);
setResult(RESULT_CANCELED);
finish();
return;
}
try {
op.executeAfterAuthentication(connectionMode, settings.getString("git_remote_username", "git"), new File(getFilesDir() + "/.ssh_key"));
} catch (Exception e) {
e.printStackTrace();
}
op.executeAfterAuthentication(connectionMode,
settings.getString("git_remote_username", "git"),
new File(getFilesDir() + "/.ssh_key"),
identity);
} catch (Exception e) {
e.printStackTrace();
new AlertDialog.Builder(this).setMessage(e.getMessage()).show();
}
}
protected void onActivityResult(int requestCode, int resultCode,
Intent data) {
// In addition to the pre-operation-launch series of intents for OpenKeychain auth
// that will pass through here and back to launchGitOperation, there is one
// synchronous operation that happens /after/ the operation has been launched in the
// background thread - the actual signing of the SSH challenge. We pass through the
// completed signature to the ApiIdentity, which will be blocked in the other thread
// waiting for it.
if (requestCode == SshApiSessionFactory.POST_SIGNATURE && identity != null)
identity.postSignature(data);
if (resultCode == RESULT_CANCELED) {
setResult(RESULT_CANCELED);
finish();
} else if (resultCode == RESULT_OK) {
// If an operation has been re-queued via this mechanism, let the
// IdentityBuilder attempt to extract some updated state from the intent before
// trying to re-launch the operation.
if (identityBuilder != null) {
identityBuilder.consume(data);
}
launchGitOperation(requestCode);
}
}

View file

@ -11,16 +11,20 @@ import android.view.View;
import android.widget.CheckBox;
import android.widget.EditText;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.KeyPair;
import com.zeapo.pwdstore.R;
import com.zeapo.pwdstore.UserPreference;
import com.zeapo.pwdstore.git.config.GitConfigSessionFactory;
import com.zeapo.pwdstore.git.config.SshApiSessionFactory;
import com.zeapo.pwdstore.git.config.SshConfigSessionFactory;
import com.zeapo.pwdstore.utils.PasswordRepository;
import org.eclipse.jgit.api.GitCommand;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.transport.JschConfigSessionFactory;
@ -76,6 +80,18 @@ public abstract class GitOperation {
return this;
}
/**
* Sets the authentication using OpenKeystore scheme
*
* @param identity The identiy to use
* @return the current object
*/
GitOperation setAuthentication(String username, SshApiSessionFactory.ApiIdentity identity) {
SshSessionFactory.setInstance(new SshApiSessionFactory(username, identity));
this.provider = null;
return this;
}
/**
* Executes the GitCommand in an async task
*/
@ -86,10 +102,14 @@ public abstract class GitOperation {
*
* @param connectionMode the server-connection mode
* @param username the username
* @param sshKey the ssh-key file
* @param sshKey the ssh-key file to use in ssh-key connection mode
* @param identity the api identity to use for auth in OpenKeychain connection mode
*/
public void executeAfterAuthentication(final String connectionMode, final String username, @Nullable final File sshKey) {
executeAfterAuthentication(connectionMode, username, sshKey, false);
public void executeAfterAuthentication(final String connectionMode,
final String username,
@Nullable final File sshKey,
SshApiSessionFactory.ApiIdentity identity) {
executeAfterAuthentication(connectionMode, username, sshKey, identity, false);
}
/**
@ -97,10 +117,15 @@ public abstract class GitOperation {
*
* @param connectionMode the server-connection mode
* @param username the username
* @param sshKey the ssh-key file
* @param sshKey the ssh-key file to use in ssh-key connection mode
* @param identity the api identity to use for auth in OpenKeychain connection mode
* @param showError show the passphrase edit text in red
*/
private void executeAfterAuthentication(final String connectionMode, final String username, @Nullable final File sshKey, final boolean showError) {
private void executeAfterAuthentication(final String connectionMode,
final String username,
@Nullable final File sshKey,
SshApiSessionFactory.ApiIdentity identity,
final boolean showError) {
if (connectionMode.equalsIgnoreCase("ssh-key")) {
if (sshKey == null || !sshKey.exists()) {
new AlertDialog.Builder(callingActivity)
@ -153,7 +178,7 @@ public abstract class GitOperation {
setAuthentication(sshKey, username, sshKeyPassphrase).execute();
} else {
// call back the method
executeAfterAuthentication(connectionMode, username, sshKey, true);
executeAfterAuthentication(connectionMode, username, sshKey, identity, true);
}
} else {
new AlertDialog.Builder(callingActivity)
@ -171,7 +196,7 @@ public abstract class GitOperation {
} else {
settings.edit().putString("ssh_key_passphrase", null).apply();
// call back the method
executeAfterAuthentication(connectionMode, username, sshKey, true);
executeAfterAuthentication(connectionMode, username, sshKey, identity, true);
}
}).setNegativeButton(callingActivity.getResources().getString(R.string.dialog_cancel), (dialog, whichButton) -> {
// Do nothing.
@ -189,6 +214,8 @@ public abstract class GitOperation {
}).show();
}
}
} else if (connectionMode.equalsIgnoreCase("OpenKeychain")) {
setAuthentication(username, identity).execute();
} else {
final EditText password = new EditText(callingActivity);
password.setHint("Password");

View file

@ -0,0 +1,358 @@
package com.zeapo.pwdstore.git.config;
import android.app.Activity;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.IntentSender;
import androidx.appcompat.app.AlertDialog;
import com.jcraft.jsch.Identity;
import com.jcraft.jsch.JSch;
import com.jcraft.jsch.JSchException;
import com.jcraft.jsch.Session;
import com.jcraft.jsch.UserInfo;
import com.zeapo.pwdstore.R;
import org.eclipse.jgit.errors.UnsupportedCredentialItem;
import org.eclipse.jgit.transport.CredentialItem;
import org.eclipse.jgit.transport.CredentialsProvider;
import org.eclipse.jgit.transport.CredentialsProviderUserInfo;
import org.eclipse.jgit.transport.OpenSshConfig;
import org.eclipse.jgit.transport.URIish;
import org.eclipse.jgit.util.Base64;
import org.eclipse.jgit.util.FS;
import org.openintents.ssh.authentication.ISshAuthenticationService;
import org.openintents.ssh.authentication.SshAuthenticationApi;
import org.openintents.ssh.authentication.SshAuthenticationApiError;
import org.openintents.ssh.authentication.SshAuthenticationConnection;
import org.openintents.ssh.authentication.request.KeySelectionRequest;
import org.openintents.ssh.authentication.request.Request;
import org.openintents.ssh.authentication.request.SigningRequest;
import org.openintents.ssh.authentication.request.SshPublicKeyRequest;
import org.openintents.ssh.authentication.util.SshAuthenticationApiUtils;
import java.util.List;
import java.util.concurrent.CountDownLatch;
public class SshApiSessionFactory extends GitConfigSessionFactory {
/**
* Intent request code indicating a completed signature that should be posted to an outstanding
* ApiIdentity
*/
public static final int POST_SIGNATURE = 301;
private String username;
private Identity identity;
public SshApiSessionFactory(String username, Identity identity) {
this.username = username;
this.identity = identity;
}
@Override
protected JSch
getJSch(final OpenSshConfig.Host hc, FS fs) throws JSchException {
JSch jsch = super.getJSch(hc, fs);
jsch.removeAllIdentity();
jsch.addIdentity(identity, null);
return jsch;
}
@Override
protected void configure(OpenSshConfig.Host hc, Session session) {
session.setConfig("StrictHostKeyChecking", "no");
session.setConfig("PreferredAuthentications", "publickey");
CredentialsProvider provider = new CredentialsProvider() {
@Override
public boolean isInteractive() {
return false;
}
@Override
public boolean supports(CredentialItem... items) {
return true;
}
@Override
public boolean get(URIish uri, CredentialItem... items) throws UnsupportedCredentialItem {
for (CredentialItem item : items) {
if (item instanceof CredentialItem.Username) {
((CredentialItem.Username) item).setValue(username);
}
}
return true;
}
};
UserInfo userInfo = new CredentialsProviderUserInfo(session, provider);
session.setUserInfo(userInfo);
}
/**
* Helper to build up an ApiIdentity via the invocation of several pending intents that
* communicate with OpenKeychain. The user of this class must handle onActivityResult and
* keep feeding the resulting intents into the IdentityBuilder until it can successfully complete
* the build.
*/
public static class IdentityBuilder {
private SshAuthenticationConnection connection;
private SshAuthenticationApi api;
private String keyId, description, alg;
private byte[] publicKey;
private Activity callingActivity;
/**
* Construct a new IdentityBuilder
*
* @param callingActivity Activity that will be used to launch pending intents and that will
* receive and handle the results.
*/
public IdentityBuilder(Activity callingActivity) {
this.callingActivity = callingActivity;
List<String> providers = SshAuthenticationApiUtils.getAuthenticationProviderPackageNames(callingActivity);
if (providers.isEmpty())
throw new RuntimeException(callingActivity.getString(R.string.no_ssh_api_provider));
// TODO: Handle multiple available providers? Are there actually any in practice beyond
// OpenKeychain?
connection = new SshAuthenticationConnection(callingActivity, providers.get(0));
}
/**
* Free any resources associated with this IdentityBuilder
*/
public void close() {
if (connection != null && connection.isConnected())
connection.disconnect();
}
/**
* Helper to invoke an OpenKeyshain SSH API method and correctly interpret the result.
*
* @param request The request intent to launch
* @param requestCode The request code to use if a pending intent needs to be sent
* @return The resulting intent if the request completed immediately, or null if we had to
* launch a pending intent to interact with the user
*/
private Intent executeApi(Request request, int requestCode) {
Intent result = api.executeApi(request.toIntent());
switch (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, -1)) {
case SshAuthenticationApi.RESULT_CODE_ERROR:
SshAuthenticationApiError error = result.getParcelableExtra(SshAuthenticationApi.EXTRA_ERROR);
throw new RuntimeException(error.getMessage());
case SshAuthenticationApi.RESULT_CODE_SUCCESS:
break;
case SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED:
PendingIntent pendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT);
try {
callingActivity.startIntentSenderForResult(pendingIntent.getIntentSender(), requestCode, null, 0, 0, 0);
return null;
} catch (IntentSender.SendIntentException e) {
e.printStackTrace();
throw new RuntimeException(callingActivity.getString(R.string.ssh_api_pending_intent_failed));
}
default:
throw new RuntimeException(callingActivity.getString(R.string.ssh_api_unknown_error));
}
return result;
}
/**
* Parse a given intent to see if it is the result of an OpenKeychain pending intent. If so,
* extract any updated state from it.
*
* @param intent The intent to inspect
*/
public void consume(Intent intent) {
if (intent == null)
return;
if (intent.hasExtra(SshAuthenticationApi.EXTRA_KEY_ID)) {
keyId = intent.getStringExtra(SshAuthenticationApi.EXTRA_KEY_ID);
description = intent.getStringExtra(SshAuthenticationApi.EXTRA_KEY_DESCRIPTION);
}
if (intent.hasExtra(SshAuthenticationApi.EXTRA_SSH_PUBLIC_KEY)) {
String keyStr = intent.getStringExtra(SshAuthenticationApi.EXTRA_SSH_PUBLIC_KEY);
String[] keyParts = keyStr.split(" ");
alg = keyParts[0];
publicKey = Base64.decode(keyParts[1]);
}
}
/**
* Try to build an ApiIdentity that will perform SSH authentication via OpenKeychain.
*
* @param requestCode The request code to use if a pending intent needs to be sent
* @return The built identity, or null of user interaction is still required (in which case
* a pending intent will have already been launched)
*/
public ApiIdentity tryBuild(int requestCode) {
// First gate, need to initiate a connection to the service and wait for it to connect.
if (api == null) {
connection.connect(new SshAuthenticationConnection.OnBound() {
@Override
public void onBound(ISshAuthenticationService sshAgent) {
api = new SshAuthenticationApi(callingActivity, sshAgent);
// We can immediately try the next phase without needing to post back
// though onActivityResult
tryBuild(requestCode);
}
@Override
public void onError() {
new AlertDialog.Builder(callingActivity).setMessage(callingActivity.getString(
R.string.openkeychain_ssh_api_connect_fail)).show();
}
});
return null;
}
// Second gate, need the user to select which key they want to use
if (keyId == null) {
consume(executeApi(new KeySelectionRequest(), requestCode));
// If we did not immediately get the result, bail for now and wait to be re-entered
if (keyId == null)
return null;
}
// Third gate, need to get the public key for the selected key. This one often does not
// need use interaction.
if (publicKey == null) {
consume(executeApi(new SshPublicKeyRequest(keyId), requestCode));
// If we did not immediately get the result, bail for now and wait to be re-entered
if (publicKey == null)
return null;
}
// Have everything we need for now, build the identify
return new ApiIdentity(keyId, description, publicKey, alg, callingActivity, api);
}
}
/**
* A Jsch identity that delegates key operations via the OpenKeychain SSH API
*/
public static class ApiIdentity implements Identity {
private String keyId, description, alg;
private byte[] publicKey;
private Activity callingActivity;
private SshAuthenticationApi api;
private CountDownLatch latch;
private byte[] signature;
ApiIdentity(String keyId, String description, byte[] publicKey, String alg, Activity callingActivity, SshAuthenticationApi api) {
this.keyId = keyId;
this.description = description;
this.publicKey = publicKey;
this.alg = alg;
this.callingActivity = callingActivity;
this.api = api;
}
@Override
public boolean setPassphrase(byte[] passphrase) throws JSchException {
// We are not encrypted with a passphrase
return true;
}
@Override
public byte[] getPublicKeyBlob() {
return publicKey;
}
/**
* Helper to handle the result of an OpenKeyshain SSH API signing request
*
* @param result The result intent to handle
* @return The signed challenge, or null if it was not immediately available, in which
* case the latch has been initialized and the pending intent started
*/
private byte[] handleSignResult(Intent result) {
switch (result.getIntExtra(SshAuthenticationApi.EXTRA_RESULT_CODE, -1)) {
case SshAuthenticationApi.RESULT_CODE_ERROR:
SshAuthenticationApiError error = result.getParcelableExtra(SshAuthenticationApi.EXTRA_ERROR);
throw new RuntimeException(error.getMessage());
case SshAuthenticationApi.RESULT_CODE_SUCCESS:
return result.getByteArrayExtra(SshAuthenticationApi.EXTRA_SIGNATURE);
case SshAuthenticationApi.RESULT_CODE_USER_INTERACTION_REQUIRED:
PendingIntent pendingIntent = result.getParcelableExtra(SshAuthenticationApi.EXTRA_PENDING_INTENT);
try {
latch = new CountDownLatch(1);
callingActivity.startIntentSenderForResult(pendingIntent.getIntentSender(), POST_SIGNATURE, null, 0, 0, 0);
return null;
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException(callingActivity.getString(R.string.ssh_api_pending_intent_failed));
}
default:
if (result.hasExtra(SshAuthenticationApi.EXTRA_CHALLENGE))
return handleSignResult(api.executeApi(result));
throw new RuntimeException(callingActivity.getString(R.string.ssh_api_unknown_error));
}
}
@Override
public byte[] getSignature(byte[] data) {
Intent request = new SigningRequest(data, keyId, SshAuthenticationApi.SHA1).toIntent();
signature = handleSignResult(api.executeApi(request));
// If we did not immediately get a signature (probable), we will block on a latch until
// the main activity gets the intent result and posts to us.
if (signature == null) {
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return signature;
}
/**
* Post a signature response back to an in-progress operation using this ApiIdentity.
*
* @param data The signature data (hopefully)
*/
public void postSignature(Intent data) {
try {
signature = handleSignResult(data);
} finally {
if (latch != null)
latch.countDown();
}
}
@Override
public boolean decrypt() {
return true;
}
@Override
public String getAlgName() {
return alg;
}
@Override
public String getName() {
return description;
}
@Override
public boolean isEncrypted() {
return false;
}
@Override
public void clear() {
}
}
}

View file

@ -3,6 +3,7 @@
<string-array name="connection_modes" translatable="false">
<item>ssh-key</item>
<item>username/password</item>
<item>OpenKeychain</item>
</string-array>
<string-array name="clone_protocols" translatable="false">
<item>ssh://</item>

View file

@ -252,4 +252,8 @@
<string name="crypto_extra_edit_hint">username: something other extra content</string>
<string name="get_last_changed_failed">Failed to get last changed date</string>
<string name="hotp_pending">Tap copy to calculate HOTP</string>
<string name="openkeychain_ssh_api_connect_fail">Failed to connect to OpenKeychain SSH API service.</string>
<string name="no_ssh_api_provider">No SSH API provider found. Is OpenKeychain installed?</string>
<string name="ssh_api_pending_intent_failed">SSH API pending intent failed</string>
<string name="ssh_api_unknown_error">Unknown SSH API Error</string>
</resources>