diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5c7a58df..a82de5dc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitActivity.java b/app/src/main/java/com/zeapo/pwdstore/git/GitActivity.java index ca451247..e945c973 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitActivity.java +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitActivity.java @@ -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); } } diff --git a/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.java b/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.java index 51ea92d3..39edf158 100644 --- a/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.java +++ b/app/src/main/java/com/zeapo/pwdstore/git/GitOperation.java @@ -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"); diff --git a/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java b/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java new file mode 100644 index 00000000..f3d2f20a --- /dev/null +++ b/app/src/main/java/com/zeapo/pwdstore/git/config/SshApiSessionFactory.java @@ -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 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() { + + } + } +} diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 44698c41..77b93647 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -3,6 +3,7 @@ ssh-key username/password + OpenKeychain ssh:// diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ba0fed4c..cc4888ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -252,4 +252,8 @@ username: something other extra content Failed to get last changed date Tap copy to calculate HOTP + Failed to connect to OpenKeychain SSH API service. + No SSH API provider found. Is OpenKeychain installed? + SSH API pending intent failed + Unknown SSH API Error