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:
parent
94bf103b33
commit
f272e4dde2
6 changed files with 493 additions and 62 deletions
|
@ -84,6 +84,7 @@ dependencies {
|
||||||
implementation("com.jayway.android.robotium:robotium-solo:5.6.3")
|
implementation("com.jayway.android.robotium:robotium-solo:5.6.3")
|
||||||
implementation(kotlin("stdlib-jdk8", KotlinCompilerVersion.VERSION))
|
implementation(kotlin("stdlib-jdk8", KotlinCompilerVersion.VERSION))
|
||||||
implementation("androidx.constraintlayout:constraintlayout:1.1.3")
|
implementation("androidx.constraintlayout:constraintlayout:1.1.3")
|
||||||
|
implementation("org.sufficientlysecure:sshauthentication-api:1.0")
|
||||||
|
|
||||||
// Testing-only dependencies
|
// Testing-only dependencies
|
||||||
androidTestImplementation("junit:junit:4.12")
|
androidTestImplementation("junit:junit:4.12")
|
||||||
|
|
|
@ -17,11 +17,15 @@ import android.widget.ArrayAdapter;
|
||||||
import android.widget.EditText;
|
import android.widget.EditText;
|
||||||
import android.widget.Spinner;
|
import android.widget.Spinner;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
import androidx.appcompat.app.AlertDialog;
|
import androidx.appcompat.app.AlertDialog;
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
import com.zeapo.pwdstore.R;
|
import com.zeapo.pwdstore.R;
|
||||||
import com.zeapo.pwdstore.UserPreference;
|
import com.zeapo.pwdstore.UserPreference;
|
||||||
|
import com.zeapo.pwdstore.git.config.SshApiSessionFactory;
|
||||||
import com.zeapo.pwdstore.utils.PasswordRepository;
|
import com.zeapo.pwdstore.utils.PasswordRepository;
|
||||||
|
|
||||||
import org.apache.commons.io.FileUtils;
|
import org.apache.commons.io.FileUtils;
|
||||||
import org.eclipse.jgit.api.Git;
|
import org.eclipse.jgit.api.Git;
|
||||||
import org.eclipse.jgit.api.RebaseCommand;
|
import org.eclipse.jgit.api.RebaseCommand;
|
||||||
|
@ -53,6 +57,8 @@ public class GitActivity extends AppCompatActivity {
|
||||||
private File localDir;
|
private File localDir;
|
||||||
private String hostname;
|
private String hostname;
|
||||||
private SharedPreferences settings;
|
private SharedPreferences settings;
|
||||||
|
private SshApiSessionFactory.IdentityBuilder identityBuilder;
|
||||||
|
private SshApiSessionFactory.ApiIdentity identity;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
@ -117,8 +123,10 @@ public class GitActivity extends AppCompatActivity {
|
||||||
connection_mode_spinner.setEnabled(true);
|
connection_mode_spinner.setEnabled(true);
|
||||||
|
|
||||||
// however, if we have some saved that, that's more important!
|
// 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);
|
connection_mode_spinner.setSelection(0);
|
||||||
|
} else if (connectionMode.equalsIgnoreCase("OpenKeychain")) {
|
||||||
|
connection_mode_spinner.setSelection(2);
|
||||||
} else {
|
} else {
|
||||||
connection_mode_spinner.setSelection(1);
|
connection_mode_spinner.setSelection(1);
|
||||||
}
|
}
|
||||||
|
@ -370,6 +378,16 @@ public class GitActivity extends AppCompatActivity {
|
||||||
updateURI();
|
updateURI();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDestroy() {
|
||||||
|
// Do not leak the service connection
|
||||||
|
if (identityBuilder != null) {
|
||||||
|
identityBuilder.close();
|
||||||
|
identityBuilder = null;
|
||||||
|
}
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onCreateOptionsMenu(Menu menu) {
|
public boolean onCreateOptionsMenu(Menu menu) {
|
||||||
// Inflate the menu; this adds items to the action bar if it is present.
|
// 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) -> {
|
(dialog, id) -> {
|
||||||
try {
|
try {
|
||||||
FileUtils.deleteDirectory(localDir);
|
FileUtils.deleteDirectory(localDir);
|
||||||
try {
|
launchGitOperation(REQUEST_CLONE);
|
||||||
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();
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
//TODO Handle the exception correctly if we are unable to delete the directory...
|
//TODO Handle the exception correctly if we are unable to delete the directory...
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
|
@ -590,15 +599,13 @@ public class GitActivity extends AppCompatActivity {
|
||||||
new AlertDialog.Builder(GitActivity.this).setMessage(e.getMessage()).show();
|
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) {
|
} catch (Exception e) {
|
||||||
//This is what happens when jgit fails :(
|
//This is what happens when jgit fails :(
|
||||||
//TODO Handle the diffent cases of exceptions
|
//TODO Handle the diffent cases of exceptions
|
||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
new AlertDialog.Builder(this).setMessage(e.getMessage()).show();
|
new AlertDialog.Builder(this).setMessage(e.getMessage()).show();
|
||||||
}
|
}
|
||||||
|
launchGitOperation(REQUEST_CLONE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -627,47 +634,45 @@ public class GitActivity extends AppCompatActivity {
|
||||||
else {
|
else {
|
||||||
// check that the remote origin is here, else add it
|
// check that the remote origin is here, else add it
|
||||||
PasswordRepository.addRemote("origin", hostname, false);
|
PasswordRepository.addRemote("origin", hostname, false);
|
||||||
GitOperation op;
|
launchGitOperation(operation);
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected void onActivityResult(int requestCode, int resultCode,
|
/**
|
||||||
Intent data) {
|
* Attempt to launch the requested GIT operation. Depending on the configured auth, it may not
|
||||||
if (resultCode == RESULT_CANCELED) {
|
* be possible to launch the operation immediately. In that case, this function may launch an
|
||||||
setResult(RESULT_CANCELED);
|
* intermediate activity instead, which will gather necessary information and post it back via
|
||||||
finish();
|
* onActivityResult, which will then re-call this function. This may happen multiple times,
|
||||||
return;
|
* 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) {
|
try {
|
||||||
GitOperation op;
|
|
||||||
|
|
||||||
switch (requestCode) {
|
// Before launching the operation with OpenKeychain auth, we need to issue several requests
|
||||||
case REQUEST_CLONE:
|
// to the OpenKeychain API. IdentityBuild will take care of launching the relevant intents,
|
||||||
setResult(RESULT_OK);
|
// we just need to keep calling it until it returns a completed ApiIdentity.
|
||||||
finish();
|
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;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (operation) {
|
||||||
|
case REQUEST_CLONE:
|
||||||
|
op = new CloneOperation(localDir, activity).setCommand(hostname);
|
||||||
|
break;
|
||||||
|
|
||||||
case REQUEST_PULL:
|
case REQUEST_PULL:
|
||||||
op = new PullOperation(localDir, activity).setCommand();
|
op = new PullOperation(localDir, activity).setCommand();
|
||||||
break;
|
break;
|
||||||
|
@ -676,22 +681,57 @@ public class GitActivity extends AppCompatActivity {
|
||||||
op = new PushOperation(localDir, activity).setCommand();
|
op = new PushOperation(localDir, activity).setCommand();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case REQUEST_SYNC:
|
||||||
|
op = new SyncOperation(localDir, activity).setCommands();
|
||||||
|
break;
|
||||||
|
|
||||||
case GitOperation.GET_SSH_KEY_FROM_CLONE:
|
case GitOperation.GET_SSH_KEY_FROM_CLONE:
|
||||||
op = new CloneOperation(localDir, activity).setCommand(hostname);
|
op = new CloneOperation(localDir, activity).setCommand(hostname);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case SshApiSessionFactory.POST_SIGNATURE:
|
||||||
|
return;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
Log.e(TAG, "Operation not recognized : " + resultCode);
|
Log.e(TAG, "Operation not recognized : " + operation);
|
||||||
setResult(RESULT_CANCELED);
|
setResult(RESULT_CANCELED);
|
||||||
finish();
|
finish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
op.executeAfterAuthentication(connectionMode,
|
||||||
op.executeAfterAuthentication(connectionMode, settings.getString("git_remote_username", "git"), new File(getFilesDir() + "/.ssh_key"));
|
settings.getString("git_remote_username", "git"),
|
||||||
} catch (Exception e) {
|
new File(getFilesDir() + "/.ssh_key"),
|
||||||
e.printStackTrace();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -11,16 +11,20 @@ import android.view.View;
|
||||||
import android.widget.CheckBox;
|
import android.widget.CheckBox;
|
||||||
import android.widget.EditText;
|
import android.widget.EditText;
|
||||||
import android.widget.LinearLayout;
|
import android.widget.LinearLayout;
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.appcompat.app.AlertDialog;
|
import androidx.appcompat.app.AlertDialog;
|
||||||
|
|
||||||
import com.jcraft.jsch.JSch;
|
import com.jcraft.jsch.JSch;
|
||||||
import com.jcraft.jsch.JSchException;
|
import com.jcraft.jsch.JSchException;
|
||||||
import com.jcraft.jsch.KeyPair;
|
import com.jcraft.jsch.KeyPair;
|
||||||
import com.zeapo.pwdstore.R;
|
import com.zeapo.pwdstore.R;
|
||||||
import com.zeapo.pwdstore.UserPreference;
|
import com.zeapo.pwdstore.UserPreference;
|
||||||
import com.zeapo.pwdstore.git.config.GitConfigSessionFactory;
|
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.git.config.SshConfigSessionFactory;
|
||||||
import com.zeapo.pwdstore.utils.PasswordRepository;
|
import com.zeapo.pwdstore.utils.PasswordRepository;
|
||||||
|
|
||||||
import org.eclipse.jgit.api.GitCommand;
|
import org.eclipse.jgit.api.GitCommand;
|
||||||
import org.eclipse.jgit.lib.Repository;
|
import org.eclipse.jgit.lib.Repository;
|
||||||
import org.eclipse.jgit.transport.JschConfigSessionFactory;
|
import org.eclipse.jgit.transport.JschConfigSessionFactory;
|
||||||
|
@ -76,6 +80,18 @@ public abstract class GitOperation {
|
||||||
return this;
|
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
|
* Executes the GitCommand in an async task
|
||||||
*/
|
*/
|
||||||
|
@ -86,10 +102,14 @@ public abstract class GitOperation {
|
||||||
*
|
*
|
||||||
* @param connectionMode the server-connection mode
|
* @param connectionMode the server-connection mode
|
||||||
* @param username the username
|
* @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) {
|
public void executeAfterAuthentication(final String connectionMode,
|
||||||
executeAfterAuthentication(connectionMode, username, sshKey, false);
|
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 connectionMode the server-connection mode
|
||||||
* @param username the username
|
* @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
|
* @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 (connectionMode.equalsIgnoreCase("ssh-key")) {
|
||||||
if (sshKey == null || !sshKey.exists()) {
|
if (sshKey == null || !sshKey.exists()) {
|
||||||
new AlertDialog.Builder(callingActivity)
|
new AlertDialog.Builder(callingActivity)
|
||||||
|
@ -153,7 +178,7 @@ public abstract class GitOperation {
|
||||||
setAuthentication(sshKey, username, sshKeyPassphrase).execute();
|
setAuthentication(sshKey, username, sshKeyPassphrase).execute();
|
||||||
} else {
|
} else {
|
||||||
// call back the method
|
// call back the method
|
||||||
executeAfterAuthentication(connectionMode, username, sshKey, true);
|
executeAfterAuthentication(connectionMode, username, sshKey, identity, true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
new AlertDialog.Builder(callingActivity)
|
new AlertDialog.Builder(callingActivity)
|
||||||
|
@ -171,7 +196,7 @@ public abstract class GitOperation {
|
||||||
} else {
|
} else {
|
||||||
settings.edit().putString("ssh_key_passphrase", null).apply();
|
settings.edit().putString("ssh_key_passphrase", null).apply();
|
||||||
// call back the method
|
// 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) -> {
|
}).setNegativeButton(callingActivity.getResources().getString(R.string.dialog_cancel), (dialog, whichButton) -> {
|
||||||
// Do nothing.
|
// Do nothing.
|
||||||
|
@ -189,6 +214,8 @@ public abstract class GitOperation {
|
||||||
}).show();
|
}).show();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (connectionMode.equalsIgnoreCase("OpenKeychain")) {
|
||||||
|
setAuthentication(username, identity).execute();
|
||||||
} else {
|
} else {
|
||||||
final EditText password = new EditText(callingActivity);
|
final EditText password = new EditText(callingActivity);
|
||||||
password.setHint("Password");
|
password.setHint("Password");
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,6 +3,7 @@
|
||||||
<string-array name="connection_modes" translatable="false">
|
<string-array name="connection_modes" translatable="false">
|
||||||
<item>ssh-key</item>
|
<item>ssh-key</item>
|
||||||
<item>username/password</item>
|
<item>username/password</item>
|
||||||
|
<item>OpenKeychain</item>
|
||||||
</string-array>
|
</string-array>
|
||||||
<string-array name="clone_protocols" translatable="false">
|
<string-array name="clone_protocols" translatable="false">
|
||||||
<item>ssh://</item>
|
<item>ssh://</item>
|
||||||
|
|
|
@ -252,4 +252,8 @@
|
||||||
<string name="crypto_extra_edit_hint">username: something other extra content</string>
|
<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="get_last_changed_failed">Failed to get last changed date</string>
|
||||||
<string name="hotp_pending">Tap copy to calculate HOTP</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>
|
</resources>
|
||||||
|
|
Loading…
Reference in a new issue