diff --git a/app/build.gradle b/app/build.gradle index 70e52cbd5..dc6500cae 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -191,8 +191,15 @@ dependencies { implementation "io.github.rburgst:okhttp-digest:$versions.okhttp_digest" + // Git sync over SSH implementation "org.eclipse.jgit:org.eclipse.jgit:$versions.jgit" - implementation "org.eclipse.jgit:org.eclipse.jgit.ssh.jsch:$versions.jgit" + implementation("org.eclipse.jgit:org.eclipse.jgit.ssh.apache:$versions.jgit") { + // Resolves DuplicatePlatformClasses lint error + exclude group: 'org.apache.sshd', module: 'sshd-osgi' + } + implementation 'androidx.security:security-crypto:1.1.0-alpha03' + implementation 'androidx.biometric:biometric-ktx:1.2.0-alpha04' + } repositories { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f8c11f441..f0189c6b8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -122,6 +122,11 @@ android:windowSoftInputMode="stateAlwaysHidden|adjustResize"> + + + diff --git a/app/src/main/java/com/orgzly/android/App.java b/app/src/main/java/com/orgzly/android/App.java index 9fc352ba6..56ec3044b 100644 --- a/app/src/main/java/com/orgzly/android/App.java +++ b/app/src/main/java/com/orgzly/android/App.java @@ -76,4 +76,7 @@ public static Context getAppContext() { public static void setCurrentActivity(CommonActivity currentCommonActivity) { currentActivity = currentCommonActivity; } + + public static CommonActivity getCurrentActivity() { return currentActivity; } + } diff --git a/app/src/main/java/com/orgzly/android/AppIntent.java b/app/src/main/java/com/orgzly/android/AppIntent.java index ec65c1e64..3ca7c5e55 100644 --- a/app/src/main/java/com/orgzly/android/AppIntent.java +++ b/app/src/main/java/com/orgzly/android/AppIntent.java @@ -38,6 +38,10 @@ public class AppIntent { public static final String ACTION_SHOW_SNACKBAR = "com.orgzly.intent.action.SHOW_SNACKBAR"; + public static final String ACTION_REJECT_REMOTE_HOST_KEY = "com.orgzly.intent.action.REJECT_REMOTE_HOST_KEY"; + public static final String ACTION_ACCEPT_REMOTE_HOST_KEY = "com.orgzly.intent.action.ACCEPT_REMOTE_HOST_KEY"; + public static final String ACTION_ACCEPT_AND_STORE_REMOTE_HOST_KEY = "com.orgzly.intent.action.ACCEPT_AND_STORE_REMOTE_HOST_KEY"; + public static final String EXTRA_MESSAGE = "com.orgzly.intent.extra.MESSAGE"; public static final String EXTRA_BOOK_ID = "com.orgzly.intent.extra.BOOK_ID"; public static final String EXTRA_BOOK_PREFACE = "com.orgzly.intent.extra.BOOK_PREFACE"; diff --git a/app/src/main/java/com/orgzly/android/NotificationChannels.kt b/app/src/main/java/com/orgzly/android/NotificationChannels.kt index 392ac84bc..0d691a895 100644 --- a/app/src/main/java/com/orgzly/android/NotificationChannels.kt +++ b/app/src/main/java/com/orgzly/android/NotificationChannels.kt @@ -20,6 +20,7 @@ object NotificationChannels { const val REMINDERS = "reminders" const val SYNC_PROGRESS = "sync-progress" const val SYNC_FAILED = "sync-failed" + const val SYNC_PROMPT = "sync-prompt" @JvmStatic fun createAll(context: Context) { @@ -28,6 +29,7 @@ object NotificationChannels { createForReminders(context) createForSyncProgress(context) createForSyncFailed(context) + createForSyncPrompt(context) } } @@ -111,4 +113,25 @@ object NotificationChannels { context.getNotificationManager().createNotificationChannel(channel) } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createForSyncPrompt(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return + } + + val id = SYNC_PROMPT + val name = "Sync prompt" + val description = "Display sync prompt" + val importance = NotificationManager.IMPORTANCE_HIGH + + val channel = NotificationChannel(id, name, importance) + + channel.description = description + + channel.setShowBadge(false) + + val mNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + mNotificationManager.createNotificationChannel(channel) + } } \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java b/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java index d0dcc9773..0fdbc6fc1 100644 --- a/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java +++ b/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java @@ -1,11 +1,15 @@ package com.orgzly.android.git; +import static com.orgzly.android.ui.AppSnackbarUtils.showSnackbar; + +import android.app.Activity; import android.content.Context; import android.net.Uri; import android.util.Log; import androidx.annotation.NonNull; +import com.orgzly.R; import com.orgzly.android.App; import com.orgzly.android.util.MiscUtils; @@ -14,7 +18,6 @@ import org.eclipse.jgit.api.MergeResult; import org.eclipse.jgit.api.ResetCommand; import org.eclipse.jgit.api.Status; -import org.eclipse.jgit.api.TransportCommand; import org.eclipse.jgit.api.errors.GitAPIException; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.ObjectId; @@ -22,6 +25,7 @@ import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevWalk; +import org.eclipse.jgit.transport.PushResult; import org.eclipse.jgit.treewalk.TreeWalk; import java.io.File; @@ -33,14 +37,18 @@ import java.util.TimeZone; public class GitFileSynchronizer { - private static String TAG = GitFileSynchronizer.class.getSimpleName(); + private final static String TAG = GitFileSynchronizer.class.getSimpleName(); + + private final Git git; + private final GitPreferences preferences; + private final Context context; + private final Activity currentActivity = App.getCurrentActivity(); - private Git git; - private GitPreferences preferences; public GitFileSynchronizer(Git g, GitPreferences prefs) { git = g; preferences = prefs; + context = App.getAppContext(); } private GitTransportSetter transportSetter() { @@ -67,12 +75,17 @@ public void retrieveLatestVersionOfFile( MiscUtils.copyFile(repoDirectoryFile(repositoryPath), destination); } - private void fetch() throws GitAPIException { - transportSetter() - .setTransport(git.fetch() - .setRemote(preferences.remoteName()) - .setRemoveDeletedRefs(true)) - .call(); + private void fetch() throws IOException { + try { + transportSetter() + .setTransport(git.fetch() + .setRemote(preferences.remoteName()) + .setRemoveDeletedRefs(true)) + .call(); + } catch (GitAPIException e) { + e.printStackTrace(); + throw new IOException(e.getMessage()); + } } public void checkoutSelected() throws GitAPIException { @@ -93,22 +106,6 @@ public boolean mergeWithRemote() throws IOException { return false; } - public boolean mergeAndPushToRemote() throws IOException { - boolean success = mergeWithRemote(); - if (success) try { - transportSetter().setTransport(git.push().setRemote(preferences.remoteName())).call(); - } catch (GitAPIException e) {} - return success; - } - - public void updateAndCommitFileFromRevision( - File sourceFile, String repositoryPath, - ObjectId fileRevision, RevCommit revision) throws IOException { - ensureRepoIsClean(); - if (updateAndCommitFileFromRevision(sourceFile, repositoryPath, fileRevision)) - return; - } - private String getShortHash(ObjectId hash) { String shortHash = hash.getName(); try { @@ -140,8 +137,8 @@ public boolean updateAndCommitFileFromRevisionAndMerge( try { git.branchDelete().setBranchNames(mergeBranch).call(); } catch (GitAPIException e) {} - Boolean mergeSucceeded = true; - Boolean doCleanup = false; + boolean mergeSucceeded = true; + boolean doCleanup = false; try { RevCommit mergeTarget = currentHead(); // Try to use the branch "orgzly-pre-sync-marker" to find a good point for branching off. @@ -239,16 +236,38 @@ public void tryPushIfHeadDiffersFromRemote() { } public void tryPush() { - final TransportCommand pushCommand = transportSetter().setTransport( + final var pushCommand = transportSetter().setTransport( git.push().setRemote(preferences.remoteName())); + final Object monitor = new Object(); App.EXECUTORS.diskIO().execute(() -> { try { - pushCommand.call(); + Iterable results = (Iterable) pushCommand.call(); + // org.eclipse.jgit.api.PushCommand swallows some errors without throwing exceptions. + if (!results.iterator().next().getMessages().isEmpty()) { + if (currentActivity != null) { + showSnackbar(currentActivity, results.iterator().next().getMessages()); + } + } + synchronized (monitor) { + monitor.notify(); + } } catch (GitAPIException e) { - e.printStackTrace(); + if (currentActivity != null) { + showSnackbar( + currentActivity, + String.format("Failed to push to remote: %s", e.getMessage()) + ); + } } }); + synchronized (monitor) { + try { + monitor.wait(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } } private void gitResetMerge() throws IOException, GitAPIException { @@ -274,7 +293,11 @@ public void setBranchAndGetLatest() throws IOException { // Point a "marker" branch to the current head, so that we know a good starting commit // for merge conflict branches. git.branchCreate().setName("orgzly-pre-sync-marker").setForce(true).call(); - fetch(); + } catch (GitAPIException e) { + throw new IOException(context.getString(R.string.git_sync_error_failed_set_marker_branch)); + } + fetch(); + try { RevCommit current = currentHead(); RevCommit mergeTarget = getCommit( String.format("%s/%s", preferences.remoteName(), git.getRepository().getBranch())); @@ -290,8 +313,7 @@ public void setBranchAndGetLatest() throws IOException { } } } catch (GitAPIException e) { - e.printStackTrace(); - throw new IOException("Failed to update from remote"); + throw new IOException(e.getMessage()); } } @@ -348,7 +370,7 @@ public void addAndCommitNewFile(File sourceFile, String repositoryPath) throws I updateAndCommitFile(sourceFile, repositoryPath); } - private RevCommit updateAndCommitFile( + private void updateAndCommitFile( File sourceFile, String repositoryPath) throws IOException { File destinationFile = repoDirectoryFile(repositoryPath); MiscUtils.copyFile(sourceFile, destinationFile); @@ -359,7 +381,6 @@ private RevCommit updateAndCommitFile( } catch (GitAPIException e) { throw new IOException("Failed to commit changes."); } - return currentHead(); } private void commit(String message) throws GitAPIException { @@ -414,9 +435,8 @@ public boolean isEmptyRepo() throws IOException{ } public ObjectId getFileRevision(String pathString, RevCommit commit) throws IOException { - ObjectId objectId = TreeWalk.forPath( + return TreeWalk.forPath( git.getRepository(), pathString, commit.getTree()).getObjectId(0); - return objectId; } public boolean fileMatchesInRevisions(String pathString, RevCommit start, RevCommit end) diff --git a/app/src/main/java/com/orgzly/android/git/GitPreferencesFromRepoPrefs.java b/app/src/main/java/com/orgzly/android/git/GitPreferencesFromRepoPrefs.java index 1f84c955f..d1468add8 100644 --- a/app/src/main/java/com/orgzly/android/git/GitPreferencesFromRepoPrefs.java +++ b/app/src/main/java/com/orgzly/android/git/GitPreferencesFromRepoPrefs.java @@ -22,9 +22,7 @@ public GitTransportSetter createTransportSetter() { return new HTTPSTransportSetter(username, password); } else { // assume SSH, since ssh:// usually isn't specified as the scheme when cloning via SSH. - String sshKeyPath = repoPreferences.getStringValueWithGlobalDefault( - R.string.pref_key_git_ssh_key_path, "orgzly"); - return new GitSSHKeyTransportSetter(sshKeyPath); + return new GitSshKeyTransportSetter(); } } diff --git a/app/src/main/java/com/orgzly/android/git/GitSSHKeyTransportSetter.java b/app/src/main/java/com/orgzly/android/git/GitSSHKeyTransportSetter.java deleted file mode 100644 index 8b9872fb1..000000000 --- a/app/src/main/java/com/orgzly/android/git/GitSSHKeyTransportSetter.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.orgzly.android.git; - -import com.jcraft.jsch.JSch; -import com.jcraft.jsch.JSchException; -import com.jcraft.jsch.Session; - -import org.eclipse.jgit.api.TransportCommand; -import org.eclipse.jgit.api.TransportConfigCallback; -import org.eclipse.jgit.transport.JschConfigSessionFactory; -import org.eclipse.jgit.transport.OpenSshConfig; -import org.eclipse.jgit.transport.SshSessionFactory; -import org.eclipse.jgit.transport.SshTransport; -import org.eclipse.jgit.transport.Transport; -import org.eclipse.jgit.util.FS; - -public class GitSSHKeyTransportSetter implements GitTransportSetter { - private String sshKeyPath; - private SshSessionFactory sshSessionFactory; - private TransportConfigCallback configCallback; - - public GitSSHKeyTransportSetter(String pathToSSHKey) { - sshKeyPath = pathToSSHKey; - sshSessionFactory = new JschConfigSessionFactory() { - @Override - protected void configure(OpenSshConfig.Host host, Session session ) { - session.setConfig("StrictHostKeyChecking", "no"); - } - - @Override - protected JSch createDefaultJSch(FS fs) throws JSchException { - JSch defaultJSch = super.createDefaultJSch(fs); - defaultJSch.addIdentity(sshKeyPath); - return defaultJSch; - } - - }; - configCallback = new TransportConfigCallback() { - @Override - public void configure(Transport transport) { - SshTransport sshTransport = (SshTransport) transport; - sshTransport.setSshSessionFactory(sshSessionFactory); - } - }; - } - - public TransportCommand setTransport(TransportCommand tc) { - tc.setTransportConfigCallback(configCallback); - return tc; - } -} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/git/GitSshKeyTransportSetter.kt b/app/src/main/java/com/orgzly/android/git/GitSshKeyTransportSetter.kt new file mode 100644 index 000000000..d9058cb6b --- /dev/null +++ b/app/src/main/java/com/orgzly/android/git/GitSshKeyTransportSetter.kt @@ -0,0 +1,88 @@ +package com.orgzly.android.git + +import android.content.Intent +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.orgzly.R +import com.orgzly.android.App +import com.orgzly.android.ui.SshKeygenActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import org.eclipse.jgit.annotations.NonNull +import org.eclipse.jgit.api.TransportCommand +import org.eclipse.jgit.api.TransportConfigCallback +import org.eclipse.jgit.internal.transport.sshd.OpenSshServerKeyDatabase +import org.eclipse.jgit.transport.SshSessionFactory +import org.eclipse.jgit.transport.SshTransport +import org.eclipse.jgit.transport.Transport +import org.eclipse.jgit.transport.sshd.ServerKeyDatabase +import org.eclipse.jgit.transport.sshd.SshdSessionFactory +import java.io.File +import java.security.KeyPair + +class GitSshKeyTransportSetter: GitTransportSetter { + private val configCallback: TransportConfigCallback + private val activity = App.getCurrentActivity() + private val context = App.getAppContext() + + init { + val factory: SshSessionFactory = object : SshdSessionFactory(null, null) { + + override fun getHomeDirectory(): File { return context.filesDir } + + override fun getDefaultPreferredAuthentications(): String { return "publickey" } + + override fun createServerKeyDatabase( + @NonNull homeDir: File, + @NonNull sshDir: File + ): ServerKeyDatabase { + // We override this method because we want to set "askAboutNewFile" to False. + return OpenSshServerKeyDatabase( + false, + getDefaultKnownHostsFiles(sshDir) + ) + } + + override fun getDefaultKeys(@NonNull sshDir: File): Iterable? { + return if (SshKey.exists) { + listOf(SshKey.getKeyPair()) + } else { + onMissingSshKeyFile() + null + } + } + } + + SshSessionFactory.setInstance(factory) + + // org.apache.sshd.common.config.keys.IdentityUtils freaks out if user.home is not set + System.setProperty("user.home", context.filesDir.toString()) + + configCallback = TransportConfigCallback { transport: Transport -> + val sshTransport = transport as SshTransport + sshTransport.sshSessionFactory = factory + } + } + + override fun setTransport(tc: TransportCommand<*, *>): TransportCommand<*, *> { + tc.setTransportConfigCallback(configCallback) + tc.setCredentialsProvider(SshCredentialsProvider()) + return tc + } + + private fun onMissingSshKeyFile() { + if (activity != null) { + val builder = MaterialAlertDialogBuilder(activity) + .setMessage(R.string.git_ssh_on_missing_key_dialog_text) + .setTitle(R.string.git_ssh_on_missing_key_dialog_title) + builder.setPositiveButton(activity.getString(R.string.yes)) { _, _ -> + val intent = + Intent(activity.applicationContext, SshKeygenActivity::class.java) + activity.startActivity(intent) + } + builder.setNegativeButton(activity.getString(R.string.not_now)) { + dialog, _ -> dialog.dismiss() + } + runBlocking(Dispatchers.Main) { activity.alertDialog = builder.show() } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/git/SshCredentialsProvider.java b/app/src/main/java/com/orgzly/android/git/SshCredentialsProvider.java new file mode 100644 index 000000000..2e8bac6a5 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/git/SshCredentialsProvider.java @@ -0,0 +1,135 @@ +package com.orgzly.android.git; + +import static com.orgzly.android.AppIntent.ACTION_ACCEPT_AND_STORE_REMOTE_HOST_KEY; +import static com.orgzly.android.AppIntent.ACTION_ACCEPT_REMOTE_HOST_KEY; +import static com.orgzly.android.AppIntent.ACTION_REJECT_REMOTE_HOST_KEY; +import static com.orgzly.android.ui.notifications.Notifications.SYNC_SSH_REMOTE_HOST_KEY; + +import android.app.NotificationManager; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; + +import com.orgzly.android.App; +import com.orgzly.android.ui.notifications.Notifications; + +import org.eclipse.jgit.errors.UnsupportedCredentialItem; +import org.eclipse.jgit.transport.CredentialItem; +import org.eclipse.jgit.transport.CredentialsProvider; +import org.eclipse.jgit.transport.URIish; + +import java.util.ArrayList; +import java.util.List; + +public class SshCredentialsProvider extends CredentialsProvider { + + private static final Object monitor = new Object(); + + public static final String DENY = "Reject"; + public static final String ALLOW = "Accept"; + public static final String ALLOW_AND_STORE = "Accept and store"; + + @Override + public boolean isInteractive() { + return true; + } + + @Override + public boolean supports(CredentialItem... items) { + for (CredentialItem i : items) { + if (i instanceof CredentialItem.YesNoType) { + continue; + } + if (i instanceof CredentialItem.InformationalMessage) { + continue; + } + return false; + } + return true; + } + + @Override + public boolean get(URIish uri, CredentialItem... items) throws UnsupportedCredentialItem { + List questions = new ArrayList<>(); + for (CredentialItem item : items) { + if (item instanceof CredentialItem.InformationalMessage) { + continue; + } + if (item instanceof CredentialItem.YesNoType) { + questions.add((CredentialItem.YesNoType) item); + continue; + } + throw new UnsupportedCredentialItem(uri, item.getClass().getName() + + ":" + item.getPromptText()); //$NON-NLS-1$ + } + + if (questions.isEmpty()) { + return true; + } else { + // We need to prompt the user via a notification; + // set up a broadcast receiver for this purpose. + Context context = App.getAppContext(); + final Boolean[] userHasResponded = {false}; + final BroadcastReceiver broadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + // Remove the notification + NotificationManager notificationManager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.cancel(SYNC_SSH_REMOTE_HOST_KEY); + // Save the user response + switch (intent.getAction()) { + case ACTION_REJECT_REMOTE_HOST_KEY: + questions.get(0).setValue(false); + break; + case ACTION_ACCEPT_REMOTE_HOST_KEY: + questions.get(0).setValue(true); + break; + case ACTION_ACCEPT_AND_STORE_REMOTE_HOST_KEY: + questions.get(0).setValue(true); + if (questions.size() == 2) { + questions.get(1).setValue(true); + } + } + userHasResponded[0] = true; + synchronized (monitor) { + monitor.notify(); + } + } + }; + // Create intent filter and register receiver + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(ACTION_REJECT_REMOTE_HOST_KEY); + intentFilter.addAction(ACTION_ACCEPT_REMOTE_HOST_KEY); + intentFilter.addAction(ACTION_ACCEPT_AND_STORE_REMOTE_HOST_KEY); + context.registerReceiver(broadcastReceiver, intentFilter); + + // Send the notification and wait up to 30 seconds for the user to respond + Notifications.showSshRemoteHostKeyPrompt(context, uri, items); + synchronized (monitor) { + if (!userHasResponded[0]) { + try { + monitor.wait(30000); + } catch (InterruptedException e) { + e.printStackTrace(); + if (!userHasResponded[0]) { + return false; + } + } + } + } + // Remove the broadcast receiver and its intent filters + context.unregisterReceiver(broadcastReceiver); + // Update the original list objects + int questionCounter = 0; + for (CredentialItem item : items) { + if (item instanceof CredentialItem.YesNoType) { + ((CredentialItem.YesNoType) item).setValue(questions.get(questionCounter).getValue()); + questionCounter++; + } + } + return questions.get(0).getValue(); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/orgzly/android/git/SshKey.kt b/app/src/main/java/com/orgzly/android/git/SshKey.kt new file mode 100644 index 000000000..686962921 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/git/SshKey.kt @@ -0,0 +1,340 @@ +package com.orgzly.android.git + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyInfo +import android.security.keystore.KeyProperties +import android.security.keystore.UserNotAuthenticatedException +import androidx.core.content.edit +import androidx.security.crypto.EncryptedFile +import androidx.security.crypto.MasterKey +import com.orgzly.R +import com.orgzly.android.App +import com.orgzly.android.prefs.AppPreferences +import com.orgzly.android.util.BiometricAuthenticator +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import net.i2p.crypto.eddsa.EdDSAPrivateKey +import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable +import net.i2p.crypto.eddsa.spec.EdDSAPrivateKeySpec +import org.apache.sshd.common.config.keys.PublicKeyEntry +import org.apache.sshd.common.config.keys.PublicKeyEntry.parsePublicKeyEntry +import java.io.File +import java.io.IOException +import java.security.* +import java.security.interfaces.RSAKey +import javax.crypto.SecretKey +import javax.crypto.SecretKeyFactory + +private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore" +private const val KEYSTORE_ALIAS = "orgzly_sshkey" +private const val ANDROIDX_SECURITY_KEYSET_PREF_NAME = "orgzly_sshkey_keyset_prefs" +private const val AUTH_VALIDITY_DURATION = 30 + +/** Alias to [lazy] with thread safety mode always set to [LazyThreadSafetyMode.NONE]. */ +private fun unsafeLazy(initializer: () -> T) = lazy(LazyThreadSafetyMode.NONE) { initializer.invoke() } + +private val androidKeystore: KeyStore by unsafeLazy { + KeyStore.getInstance(PROVIDER_ANDROID_KEY_STORE).apply { load(null) } +} + +private val KeyStore.sshPrivateKey + get() = getKey(KEYSTORE_ALIAS, null) as? PrivateKey + +private val KeyStore.sshPublicKey + get() = getCertificate(KEYSTORE_ALIAS)?.publicKey + +fun parseSshPublicKey(sshPublicKey: String): PublicKey? { + return parsePublicKeyEntry(sshPublicKey).resolvePublicKey(null, null, null) +} + +fun toSshPublicKey(publicKey: PublicKey): String { + return PublicKeyEntry.toString(publicKey) +} + +object SshKey { + val sshPublicKey + get() = if (publicKeyFile.exists()) publicKeyFile.readText() else null + val canShowSshPublicKey + get() = type in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519) + val exists + get() = type != null + private val mustAuthenticate: Boolean + get() { + return runCatching { + if (type !in listOf(Type.KeystoreNative, Type.KeystoreWrappedEd25519)) return false + when (val key = androidKeystore.getKey(KEYSTORE_ALIAS, null)) { + is PrivateKey -> { + val factory = + KeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) + return factory.getKeySpec( + key, + KeyInfo::class.java + ).isUserAuthenticationRequired + } + is SecretKey -> { + val factory = + SecretKeyFactory.getInstance(key.algorithm, PROVIDER_ANDROID_KEY_STORE) + (factory.getKeySpec( + key, + KeyInfo::class.java + ) as KeyInfo).isUserAuthenticationRequired + } + else -> throw IllegalStateException("SSH key does not exist in Keystore") + } + } + .getOrElse { + // It is fine to swallow the exception here since it will reappear when the key + // is used for SSH authentication and can then be shown in the UI. + false + } + } + + private val context: Context + get() = App.getAppContext() + + private val privateKeyFile + get() = File(context.filesDir, "ssh_key") + private val publicKeyFile + get() = File(context.filesDir, "ssh_key.pub") + + private var type: Type? + get() = Type.fromValue(AppPreferences.gitSshKeyType(context)) + set(value) = AppPreferences.gitSshKeyType(context, value?.value) + + private val isStrongBoxSupported by unsafeLazy { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + context.packageManager.hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE) + else false + } + + private enum class Type(val value: String) { + KeystoreNative("keystore_native"), + KeystoreWrappedEd25519("keystore_wrapped_ed25519"), + ; + + companion object { + fun fromValue(value: String?): Type? = values().associateBy { it.value }[value] + } + } + + enum class Algorithm( + val algorithm: String, + val applyToSpec: KeyGenParameterSpec.Builder.() -> Unit + ) { + Rsa( + KeyProperties.KEY_ALGORITHM_RSA, + { + setKeySize(3072) + setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1) + setDigests( + KeyProperties.DIGEST_SHA1, + KeyProperties.DIGEST_SHA256, + KeyProperties.DIGEST_SHA512 + ) + } + ), + + Ecdsa( + KeyProperties.KEY_ALGORITHM_EC, + { + setKeySize(256) + setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1")) + setDigests(KeyProperties.DIGEST_SHA256) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + setIsStrongBoxBacked(isStrongBoxSupported) + } + } + ), + } + + private fun delete() { + androidKeystore.deleteEntry(KEYSTORE_ALIAS) + // Remove Tink key set used by AndroidX's EncryptedFile. + context.getSharedPreferences(ANDROIDX_SECURITY_KEYSET_PREF_NAME, Context.MODE_PRIVATE) + .edit { + clear() + } + if (privateKeyFile.isFile) { + privateKeyFile.delete() + } + if (publicKeyFile.isFile) { + publicKeyFile.delete() + } + type = null + } + + private suspend fun getOrCreateWrappingMasterKey(requireAuthentication: Boolean) = + withContext(Dispatchers.IO) { + MasterKey.Builder(context, KEYSTORE_ALIAS).run { + setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + setRequestStrongBoxBacked(true) + setUserAuthenticationRequired(requireAuthentication, AUTH_VALIDITY_DURATION) + build() + } + } + + private suspend fun getOrCreateWrappedPrivateKeyFile(requireAuthentication: Boolean) = + withContext(Dispatchers.IO) { + EncryptedFile.Builder( + context, + privateKeyFile, + getOrCreateWrappingMasterKey(requireAuthentication), + EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB + ) + .run { + setKeysetPrefName(ANDROIDX_SECURITY_KEYSET_PREF_NAME) + build() + } + } + + + @Suppress("BlockingMethodInNonBlockingContext") + suspend fun generateKeystoreWrappedEd25519Key(requireAuthentication: Boolean) = + withContext(Dispatchers.IO) { + delete() + + val encryptedPrivateKeyFile = getOrCreateWrappedPrivateKeyFile(requireAuthentication) + // Generate the ed25519 key pair and encrypt the private key. + val keyPair = net.i2p.crypto.eddsa.KeyPairGenerator().generateKeyPair() + encryptedPrivateKeyFile.openFileOutput().use { os -> + os.write((keyPair.private as EdDSAPrivateKey).seed) + } + + // Write public key in SSH format to .ssh_key.pub. + publicKeyFile.writeText(toSshPublicKey(keyPair.public)) + + type = Type.KeystoreWrappedEd25519 + } + + fun generateKeystoreNativeKey(algorithm: Algorithm, requireAuthentication: Boolean) { + delete() + + // Generate Keystore-backed private key. + val parameterSpec = + KeyGenParameterSpec.Builder(KEYSTORE_ALIAS, KeyProperties.PURPOSE_SIGN).run { + apply(algorithm.applyToSpec) + if (requireAuthentication) { + setUserAuthenticationRequired(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + setUserAuthenticationParameters(AUTH_VALIDITY_DURATION, KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL) + } else { + @Suppress("DEPRECATION") setUserAuthenticationValidityDurationSeconds( + AUTH_VALIDITY_DURATION) + } + } + build() + } + val keyPair = + KeyPairGenerator.getInstance(algorithm.algorithm, PROVIDER_ANDROID_KEY_STORE).run { + initialize(parameterSpec) + generateKeyPair() + } + + // Write public key in SSH format to ssh_key.pub + publicKeyFile.writeText(toSshPublicKey(keyPair.public)) + + type = Type.KeystoreNative + } + + fun getKeyPair(): KeyPair { + var privateKey: PrivateKey? = null + var privateKeyLoadAttempts = 0 + val publicKey: PublicKey? = when (type) { + Type.KeystoreNative -> { + kotlin.runCatching { androidKeystore.sshPublicKey } + .getOrElse { error -> + throw IOException( + context.getString(R.string.ssh_key_failed_get_public), + error + ) + } + } + Type.KeystoreWrappedEd25519 -> { + runCatching { parseSshPublicKey(sshPublicKey!!) } + .getOrElse { error -> + throw IOException(context.getString(R.string.ssh_key_failed_get_public), error) + } + } + else -> throw IllegalStateException("SSH key does not exist in Keystore") + } + while (privateKeyLoadAttempts < 2) { + privateKey = when (type) { + Type.KeystoreNative -> { + runCatching { androidKeystore.sshPrivateKey } + .getOrElse { error -> + throw IOException( + context.getString(R.string.ssh_key_failed_get_private), + error + ) + } + } + Type.KeystoreWrappedEd25519 -> { + runCatching { + // The current MasterKey API does not allow getting a reference to an existing + // one without specifying the KeySpec for a new one. However, the value for + // passed here for `requireAuthentication` is not used as the key already exists + // at this point. + val encryptedPrivateKeyFile = runBlocking { + getOrCreateWrappedPrivateKeyFile(false) + } + val rawPrivateKey = + encryptedPrivateKeyFile.openFileInput().use { it.readBytes() } + EdDSAPrivateKey( + EdDSAPrivateKeySpec( + rawPrivateKey, EdDSANamedCurveTable.ED_25519_CURVE_SPEC + ) + ) + }.getOrElse { error -> + throw IOException(context.getString(R.string.ssh_key_failed_get_private), error) + } + } + else -> throw IllegalStateException("SSH key does not exist in Keystore") + } + try { + // Try to sign something to see if the key is unlocked + val algorithm: String = if (privateKey is RSAKey) { + "SHA256withRSA" + } else { + "SHA256withECDSA" + } + Signature.getInstance(algorithm).apply { + initSign(privateKey) + update("loremipsum".toByteArray()) + }.sign() + // The key is unlocked; exit the loop. + break + } catch (e: UserNotAuthenticatedException) { + if (privateKeyLoadAttempts > 0) { + // We expect this exception before trying auth, but after that, this means + // we have failed to unlock the SSH key. + throw IOException(context.getString(R.string.ssh_key_failed_unlock_private)) + } + } catch (e: Exception) { + // Our attempt to use the key for signing may go wrong in many unforeseen ways. + // Such failures are unimportant. + e.printStackTrace() + } + if (mustAuthenticate && privateKeyLoadAttempts == 0) { + // Time to try biometric auth + val currentActivity = App.getCurrentActivity() + checkNotNull(currentActivity) { + throw IOException(context.getString(R.string.ssh_key_locked_and_no_activity)) + } + val biometricAuthenticator = BiometricAuthenticator(currentActivity) + runBlocking(Dispatchers.Main) { + biometricAuthenticator.authenticate( + context.getString( + R.string.biometric_prompt_title_unlock_ssh_key + ) + ) + } + } + privateKeyLoadAttempts++ + } + return KeyPair(publicKey, privateKey) + } +} diff --git a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java index 596702ef1..33486ad45 100644 --- a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java +++ b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java @@ -902,37 +902,21 @@ public static void dropboxToken(Context context, String value) { * Git Sync */ - public static boolean gitIsEnabled(Context context) { - return getDefaultSharedPreferences(context).getBoolean( - context.getResources().getString(R.string.pref_key_git_is_enabled), - context.getResources().getBoolean(R.bool.pref_default_git_is_enabled)); - } - - public static String gitAuthor(Context context) { - return getStateSharedPreferences(context).getString("pref_key_git_author", null); - } - - public static void gitAuthor(Context context, String value) { - getStateSharedPreferences(context).edit().putString("pref_key_git_author", value).apply(); - } - - public static String gitEmail(Context context) { - return getStateSharedPreferences(context).getString("pref_key_git_email", null); - } - - public static void gitEmail(Context context, String value) { - getStateSharedPreferences(context).edit().putString("pref_key_git_email", value).apply(); + public static String gitSshKeyType(Context context) { + return getDefaultSharedPreferences(context).getString( + "pref_key_git_ssh_key_type", null); } - public static String gitSSHKeyPath(Context context) { - return getStateSharedPreferences(context).getString("pref_key_git_ssh_key_path", null); + public static void gitSshKeyType(Context context, String value) { + getDefaultSharedPreferences(context).edit().putString("pref_key_git_ssh_key_type", value).apply(); } - public static void gitSSHKeyPath(Context context, String value) { - getStateSharedPreferences(context).edit().putString( - "pref_key_git_ssh_key_path", value).apply(); + public static boolean gitIsEnabled(Context context) { + return getDefaultSharedPreferences(context).getBoolean( + context.getResources().getString(R.string.pref_key_git_is_enabled), + context.getResources().getBoolean(R.bool.pref_default_git_is_enabled)); } - + public static String defaultRepositoryStorageDirectory(Context context) { File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); return getStringFromSelector( diff --git a/app/src/main/java/com/orgzly/android/ui/SshKeygenActivity.kt b/app/src/main/java/com/orgzly/android/ui/SshKeygenActivity.kt new file mode 100644 index 000000000..f2d87f086 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/SshKeygenActivity.kt @@ -0,0 +1,149 @@ +package com.orgzly.android.ui + +import android.app.KeyguardManager +import android.os.Build +import android.os.Bundle +import android.security.keystore.UserNotAuthenticatedException +import android.view.MenuItem +import android.view.View +import android.view.inputmethod.InputMethodManager +import androidx.annotation.RequiresApi +import androidx.core.content.getSystemService +import androidx.lifecycle.lifecycleScope +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.orgzly.R +import com.orgzly.android.git.SshKey +import com.orgzly.android.ui.dialogs.ShowSshKeyDialogFragment +import com.orgzly.android.util.BiometricAuthenticator +import com.orgzly.databinding.ActivitySshKeygenBinding +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.IOException + +private enum class KeyGenType(val generateKey: suspend (requireAuthentication: Boolean) -> Unit) { + Rsa({ requireAuthentication -> + SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Rsa, requireAuthentication) + }), + Ecdsa({ requireAuthentication -> + SshKey.generateKeystoreNativeKey(SshKey.Algorithm.Ecdsa, requireAuthentication) + }), + Ed25519({ requireAuthentication -> + SshKey.generateKeystoreWrappedEd25519Key(requireAuthentication) + }), +} + +class SshKeygenActivity : CommonActivity() { + + private var keyGenType = KeyGenType.Ecdsa + private lateinit var binding: ActivitySshKeygenBinding + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivitySshKeygenBinding.inflate(layoutInflater) + setContentView(binding.root) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + with(binding) { + generate.setOnClickListener { + if (SshKey.exists) { + MaterialAlertDialogBuilder(this@SshKeygenActivity).run { + setTitle(R.string.ssh_keygen_existing_title) + setMessage(R.string.ssh_keygen_existing_message) + setPositiveButton(R.string.ssh_keygen_existing_replace) { _, _ -> + lifecycleScope.launch { generate() } + } + setNegativeButton(R.string.ssh_keygen_existing_keep) { _, _ -> + setResult(RESULT_CANCELED) + } + show() + } + } else { + lifecycleScope.launch { generate() } + } + } + keyTypeGroup.check(R.id.key_type_ecdsa) + keyTypeExplanation.setText(R.string.ssh_keygen_explanation_ecdsa) + keyTypeGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (isChecked) { + keyGenType = + when (checkedId) { + R.id.key_type_ed25519 -> KeyGenType.Ed25519 + R.id.key_type_ecdsa -> KeyGenType.Ecdsa + R.id.key_type_rsa -> KeyGenType.Rsa + else -> throw IllegalStateException("Impossible key type selection") + } + keyTypeExplanation.setText( + when (keyGenType) { + KeyGenType.Ed25519 -> R.string.ssh_keygen_explanation_ed25519 + KeyGenType.Ecdsa -> R.string.ssh_keygen_explanation_ecdsa + KeyGenType.Rsa -> R.string.ssh_keygen_explanation_rsa + } + ) + } + } + val keyguardManager: KeyguardManager = getSystemService(KEYGUARD_SERVICE) as KeyguardManager + keyRequireAuthentication.isEnabled = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + false + } else { + keyguardManager.isDeviceSecure + } + keyRequireAuthentication.isChecked = keyRequireAuthentication.isEnabled + } + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + android.R.id.home -> { + onBackPressedDispatcher.onBackPressed() + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private suspend fun generate() { + binding.generate.apply { + text = getString(R.string.ssh_keygen_generating_progress) + isEnabled = false + } + val biometricAuthenticator = BiometricAuthenticator(this) + val result: Result = runCatching { + val requireAuthentication = binding.keyRequireAuthentication.isChecked + if (requireAuthentication) { + withContext(Dispatchers.Main) { + val result = biometricAuthenticator.authenticate(getString(R.string.biometric_prompt_title_ssh_keygen)) + if (result != null) + throw UserNotAuthenticatedException(result) + } + } + keyGenType.generateKey(requireAuthentication) + } + binding.generate.apply { + text = getString(R.string.ssh_keygen_generate) + isEnabled = true + } + result.fold( + onSuccess = { ShowSshKeyDialogFragment().show(supportFragmentManager, "public_key") }, + onFailure = { e -> + e.printStackTrace() + MaterialAlertDialogBuilder(this) + .setTitle(getString(R.string.error_generate_ssh_key)) + .setMessage(getString(R.string.ssh_key_error_dialog_text) + e.message) + .setPositiveButton(getString(R.string.ok)) { _, _ -> + setResult(RESULT_OK) + } + .show() + }, + ) + hideKeyboard() + } + + private fun hideKeyboard() { + val imm = getSystemService() ?: return + var view = currentFocus + if (view == null) { + view = View(this) + } + imm.hideSoftInputFromWindow(view.windowToken, 0) + } +} diff --git a/app/src/main/java/com/orgzly/android/ui/dialogs/ShowSshKeyDialogFragment.kt b/app/src/main/java/com/orgzly/android/ui/dialogs/ShowSshKeyDialogFragment.kt new file mode 100644 index 000000000..bd5eeea75 --- /dev/null +++ b/app/src/main/java/com/orgzly/android/ui/dialogs/ShowSshKeyDialogFragment.kt @@ -0,0 +1,35 @@ +package com.orgzly.android.ui.dialogs + +import android.app.Dialog +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.orgzly.R +import com.orgzly.android.git.SshKey.sshPublicKey +import com.orgzly.android.ui.SshKeygenActivity + +class ShowSshKeyDialogFragment : DialogFragment() { + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val activity = requireActivity() + return MaterialAlertDialogBuilder(activity).run { + setMessage(getString(R.string.ssh_keygen_message, sshPublicKey)) + setTitle(R.string.your_public_key) + setNegativeButton(R.string.not_now) { _, _ -> + (activity as? SshKeygenActivity)?.finish() + } + setPositiveButton(R.string.ssh_keygen_share) { _, _ -> + val sendIntent = + Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, sshPublicKey) + } + startActivity(Intent.createChooser(sendIntent, null)) + (activity as? SshKeygenActivity)?.finish() + } + create() + } + } +} diff --git a/app/src/main/java/com/orgzly/android/ui/notifications/Notifications.java b/app/src/main/java/com/orgzly/android/ui/notifications/Notifications.java index 321689b12..794fe9c58 100644 --- a/app/src/main/java/com/orgzly/android/ui/notifications/Notifications.java +++ b/app/src/main/java/com/orgzly/android/ui/notifications/Notifications.java @@ -1,7 +1,27 @@ package com.orgzly.android.ui.notifications; +import static com.orgzly.android.AppIntent.ACTION_ACCEPT_AND_STORE_REMOTE_HOST_KEY; +import static com.orgzly.android.AppIntent.ACTION_ACCEPT_REMOTE_HOST_KEY; +import static com.orgzly.android.AppIntent.ACTION_REJECT_REMOTE_HOST_KEY; import static com.orgzly.android.NewNoteBroadcastReceiver.NOTE_TITLE; +import static com.orgzly.android.git.SshCredentialsProvider.ALLOW; +import static com.orgzly.android.git.SshCredentialsProvider.ALLOW_AND_STORE; +import static com.orgzly.android.git.SshCredentialsProvider.DENY; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import static com.orgzly.android.AppIntent.ACTION_ACCEPT_AND_STORE_REMOTE_HOST_KEY; +import static com.orgzly.android.AppIntent.ACTION_ACCEPT_REMOTE_HOST_KEY; +import static com.orgzly.android.AppIntent.ACTION_REJECT_REMOTE_HOST_KEY; +import static com.orgzly.android.NewNoteBroadcastReceiver.NOTE_TITLE; +import static com.orgzly.android.git.SshCredentialsProvider.ALLOW; +import static com.orgzly.android.git.SshCredentialsProvider.ALLOW_AND_STORE; +import static com.orgzly.android.git.SshCredentialsProvider.DENY; + +import android.app.Notification; +import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; @@ -24,6 +44,10 @@ import com.orgzly.android.ui.util.SystemServices; import com.orgzly.android.util.LogUtils; +import org.eclipse.jgit.internal.transport.sshd.SshdText; +import org.eclipse.jgit.transport.CredentialItem; +import org.eclipse.jgit.transport.URIish; + public class Notifications { public static final String TAG = Notifications.class.getName(); @@ -32,6 +56,7 @@ public class Notifications { public static final int REMINDERS_SUMMARY_ID = 3; public static final int SYNC_IN_PROGRESS_ID = 4; public static final int SYNC_FAILED_ID = 5; + public static final int SYNC_SSH_REMOTE_HOST_KEY = 6; public static final String REMINDERS_GROUP = "com.orgzly.notification.group.REMINDERS"; @@ -119,4 +144,77 @@ private static int getNotificationPriority(String priority) { public static void cancelNewNoteNotification(Context context) { SystemServices.getNotificationManager(context).cancel(ONGOING_NEW_NOTE_ID); } -} \ No newline at end of file + + /* + Expandable notification to show when a Git sync repository server + presents an unknown or unexpected SSH public key. + Presents either two or three choices to the user. The selected action + is passed back to the SshCredentialsProvider via a broadcast receiver. + */ + public static void showSshRemoteHostKeyPrompt(Context context, URIish uri, CredentialItem... items) { + // Parse CredentialItems + List messages = new ArrayList<>(); + List questions = new ArrayList<>(); + for (CredentialItem item : items) { + messages.add(item.getPromptText()); + if (item instanceof CredentialItem.YesNoType) { + questions.add((CredentialItem.YesNoType) item); + } + } + String bigText = String.join("\n", messages.subList(1, messages.size())); + + // FIXME: The "modified key" prompt is too long to fit into a BigTextStyle notification. + // Should we launch a dialog-themed activity from the notification? + if (Objects.equals(questions.get(0).getPromptText(), SshdText.get().knownHostsModifiedKeyAcceptPrompt)) { + // Remove SHA256 checksums (only show MD5) + messages.remove(5); + messages.remove(8); + bigText = String.join("\n", messages.subList(3, messages.size() - 2)); + } + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, NotificationChannels.SYNC_PROMPT) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setSmallIcon(R.drawable.cic_logo_for_notification) + .setColor(ContextCompat.getColor(context, R.color.notification)) + .setContentTitle(String.format("Accept public key for %s?", uri.getHost())) // TODO: strings.xml + .setCategory(NotificationCompat.CATEGORY_ERROR) + .setContentText(messages.get(0)) + .setStyle(new NotificationCompat.BigTextStyle() + .bigText(bigText)); + + if (!questions.isEmpty()) { + builder.addAction( + R.drawable.cic_logo_for_notification, + DENY, + PendingIntent.getBroadcast( + context, + 0, + new Intent().setAction(ACTION_REJECT_REMOTE_HOST_KEY), + PendingIntent.FLAG_IMMUTABLE)); + // Middle button is only relevant when there are 2 questions + if (questions.size() == 2) { + builder.addAction( + R.drawable.cic_logo_for_notification, + ALLOW, + PendingIntent.getBroadcast( + context, + 0, + new Intent().setAction(ACTION_ACCEPT_REMOTE_HOST_KEY), + PendingIntent.FLAG_IMMUTABLE)); + } + builder.addAction( + R.drawable.cic_logo_for_notification, + ALLOW_AND_STORE, + PendingIntent.getBroadcast( + context, + 0, + new Intent().setAction(ACTION_ACCEPT_AND_STORE_REMOTE_HOST_KEY), + PendingIntent.FLAG_IMMUTABLE)); + } + + NotificationManager notificationManager = + (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + notificationManager.notify(SYNC_SSH_REMOTE_HOST_KEY, builder.build()); + } +} diff --git a/app/src/main/java/com/orgzly/android/ui/repo/git/GitRepoActivity.kt b/app/src/main/java/com/orgzly/android/ui/repo/git/GitRepoActivity.kt index b4b330487..06285dd2e 100644 --- a/app/src/main/java/com/orgzly/android/ui/repo/git/GitRepoActivity.kt +++ b/app/src/main/java/com/orgzly/android/ui/repo/git/GitRepoActivity.kt @@ -9,6 +9,7 @@ import android.net.Uri import android.os.AsyncTask import android.os.Bundle import android.os.Environment +import android.provider.Settings import android.text.TextUtils import android.view.ContextMenu import android.view.Menu @@ -21,7 +22,7 @@ import com.google.android.material.textfield.TextInputLayout import com.orgzly.R import com.orgzly.android.App import com.orgzly.android.git.GitPreferences -import com.orgzly.android.git.GitSSHKeyTransportSetter +import com.orgzly.android.git.GitSshKeyTransportSetter import com.orgzly.android.git.GitTransportSetter import com.orgzly.android.git.HTTPSTransportSetter import com.orgzly.android.prefs.AppPreferences @@ -36,7 +37,7 @@ import com.orgzly.android.ui.showSnackbar import com.orgzly.android.util.AppPermissions import com.orgzly.android.util.MiscUtils import com.orgzly.databinding.ActivityRepoGitBinding -import org.eclipse.jgit.api.errors.TransportException +import org.eclipse.jgit.errors.TransportException import org.eclipse.jgit.errors.NoRemoteRepositoryException import org.eclipse.jgit.errors.NotSupportedException import org.eclipse.jgit.lib.ProgressMonitor @@ -79,10 +80,6 @@ class GitRepoActivity : CommonActivity(), GitPreferences { R.string.pref_key_git_https_password, true ), - Field( - binding.activityRepoGitSshKey, - binding.activityRepoGitSshKeyLayout, - R.string.pref_key_git_ssh_key_path), Field( binding.activityRepoGitAuthor, binding.activityRepoGitAuthorLayout, @@ -106,24 +103,26 @@ class GitRepoActivity : CommonActivity(), GitPreferences { startLocalFileBrowser(binding.activityRepoGitDirectory, ACTIVITY_REQUEST_CODE_FOR_DIRECTORY_SELECTION) } - binding.activityRepoGitSshKeyBrowse.setOnClickListener { - startLocalFileBrowser(binding.activityRepoGitSshKey, ACTIVITY_REQUEST_CODE_FOR_SSH_KEY_SELECTION, true) - } - val repoId = intent.getLongExtra(ARG_REPO_ID, 0) val factory = RepoViewModelFactory.getInstance(dataRepository, repoId) viewModel = ViewModelProvider(this, factory).get(RepoViewModel::class.java) - /* Set directory value for existing repository being edited. */ if (repoId != 0L) { + /* Set directory value for existing repository being edited. */ dataRepository.getRepo(repoId)?.let { repo -> binding.activityRepoGitUrl.setText(repo.url) setFromPreferences() } } else { + /* Set default values for new repo being added. */ createDefaultRepoFolder() + binding.activityRepoGitAuthor.setText("Orgzly") + binding.activityRepoGitBranch.setText(R.string.git_default_branch) + val deviceName = Settings.Secure.getString(contentResolver, "bluetooth_name") + if (deviceName != null) binding.activityRepoGitEmail.setText(String.format("orgzly@%s", deviceName)) + else binding.activityRepoGitEmail.setText("orgzly@phone") } viewModel.finishEvent.observeSingle(this, Observer { @@ -190,14 +189,10 @@ class GitRepoActivity : CommonActivity(), GitPreferences { private fun updateAuthVisibility() { val repoScheme = getRepoScheme() if ("https" == repoScheme) { - binding.activityRepoGitSshAuthInfo.visibility = View.GONE - binding.activityRepoGitSshKeyLayout.visibility = View.GONE binding.activityRepoGitHttpsAuthInfo.visibility = View.VISIBLE binding.activityRepoGitHttpsUsernameLayout.visibility = View.VISIBLE binding.activityRepoGitHttpsPasswordLayout.visibility = View.VISIBLE } else { - binding.activityRepoGitSshAuthInfo.visibility = View.VISIBLE - binding.activityRepoGitSshKeyLayout.visibility = View.VISIBLE binding.activityRepoGitHttpsAuthInfo.visibility = View.GONE binding.activityRepoGitHttpsUsernameLayout.visibility = View.GONE binding.activityRepoGitHttpsPasswordLayout.visibility = View.GONE @@ -230,7 +225,6 @@ class GitRepoActivity : CommonActivity(), GitPreferences { private fun setTextFromPrefKey(prefs: RepoPreferences, editText: EditText, prefKey: Int) { if (editText.length() < 1) { - val setting = prefs.getStringValue(prefKey, "") editText.setText(prefs.getStringValue(prefKey, "")) } } @@ -269,8 +263,18 @@ class GitRepoActivity : CommonActivity(), GitPreferences { private fun saveAndFinish() { if (validateFields()) { - // TODO: If this fails we should notify the user in a nice way and mark the git repo field as bad - RepoCloneTask(this).execute() + val repoId = intent.getLongExtra(ARG_REPO_ID, 0) + if (repoId != 0L) { + save() + } else { + val targetDirectory = File(binding.activityRepoGitDirectory.text.toString()) + if (targetDirectory.list()!!.isNotEmpty()) { + binding.activityRepoGitDirectoryLayout.error = getString(R.string.git_clone_error_target_not_empty) + } else { + // TODO: If this fails we should notify the user in a nice way and mark the git repo field as bad + RepoCloneTask(this).execute() + } + } } } @@ -278,17 +282,26 @@ class GitRepoActivity : CommonActivity(), GitPreferences { if (e == null) { save() } else { - val errorId = when { - // TODO: show error for invalid username/password when using HTTPS - e.cause is NoRemoteRepositoryException -> R.string.git_clone_error_invalid_repo - e.cause is TransportException -> R.string.git_clone_error_ssh_auth + val error = when (e.cause) { + is NoRemoteRepositoryException -> R.string.git_clone_error_invalid_repo + is TransportException -> { + // JGit's catch-all "remote hung up unexpectedly" message is not very useful. + if (Regex("hung up unexpectedly").containsMatchIn(e.cause!!.message!!)) { + String.format(getString(R.string.git_clone_error_ssh), e.cause!!.cause!!.message) + } else { + String.format(getString(R.string.git_clone_error_ssh), e.cause!!.message) + } + } // TODO: This should be checked when the user enters a directory by hand - e.cause is FileNotFoundException -> R.string.git_clone_error_invalid_target_dir - e.cause is GitRepo.DirectoryNotEmpty -> R.string.git_clone_error_target_not_empty - e.cause is NotSupportedException -> R.string.git_clone_error_uri_not_supported + is FileNotFoundException -> R.string.git_clone_error_invalid_target_dir + is GitRepo.DirectoryNotEmpty -> R.string.git_clone_error_target_not_empty + is NotSupportedException -> R.string.git_clone_error_uri_not_supported else -> R.string.git_clone_error_unknown } - showSnackbar(errorId) + when (error) { + is Int -> { showSnackbar(error) } + is String -> { showSnackbar(error) } + } e.printStackTrace() } } @@ -323,8 +336,8 @@ class GitRepoActivity : CommonActivity(), GitPreferences { } val targetDirectory = File(binding.activityRepoGitDirectory.text.toString()) - if (!targetDirectory.exists() || targetDirectory.list().isNotEmpty()) { - binding.activityRepoGitDirectoryLayout.error = getString(R.string.git_clone_error_target_not_empty) + if (!targetDirectory.exists()) { + binding.activityRepoGitDirectoryLayout.error = getString(R.string.git_clone_error_invalid_target_dir) } for (field in fields) { @@ -368,8 +381,7 @@ class GitRepoActivity : CommonActivity(), GitPreferences { val password = withDefault(binding.activityRepoGitHttpsPassword.text.toString(), R.string.pref_key_git_https_password) return HTTPSTransportSetter(username, password) } else { - val sshKeyPath = withDefault(binding.activityRepoGitSshKey.text.toString(), R.string.pref_key_git_ssh_key_path) - return GitSSHKeyTransportSetter(sshKeyPath) + return GitSshKeyTransportSetter() } } @@ -429,11 +441,6 @@ class GitRepoActivity : CommonActivity(), GitPreferences { val uri = data.data binding.activityRepoGitDirectory.setText(uri?.path) } - ACTIVITY_REQUEST_CODE_FOR_SSH_KEY_SELECTION -> - if (resultCode == Activity.RESULT_OK && data != null) { - val uri = data.data - binding.activityRepoGitSshKey.setText(uri?.path) - } } } @@ -507,7 +514,6 @@ class GitRepoActivity : CommonActivity(), GitPreferences { private const val ARG_REPO_ID = "repo_id" const val ACTIVITY_REQUEST_CODE_FOR_DIRECTORY_SELECTION = 0 - const val ACTIVITY_REQUEST_CODE_FOR_SSH_KEY_SELECTION = 1 @JvmStatic @JvmOverloads diff --git a/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt b/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt index 960d5dd7b..0681cdf95 100644 --- a/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/com/orgzly/android/ui/settings/SettingsFragment.kt @@ -12,10 +12,12 @@ import com.orgzly.BuildConfig import com.orgzly.R import com.orgzly.android.AppIntent import com.orgzly.android.SharingShortcutsManager +import com.orgzly.android.git.SshKey import com.orgzly.android.prefs.* import com.orgzly.android.reminders.RemindersScheduler import com.orgzly.android.ui.CommonActivity import com.orgzly.android.ui.NoteStates +import com.orgzly.android.ui.dialogs.ShowSshKeyDialogFragment import com.orgzly.android.ui.notifications.Notifications import com.orgzly.android.ui.util.KeyboardUtils import com.orgzly.android.usecase.NoteReparseStateAndTitles @@ -97,6 +99,31 @@ class SettingsFragment : PreferenceFragmentCompat(), SharedPreferences.OnSharedP } } + // Disable Git repos completely on API < 23 + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + preference(R.string.pref_key_git_is_enabled)?.let { + preferenceScreen.removePreference(it) + } + } + + /* Disable SSH key generation if Git repository type is not enabled */ + if (!AppPreferences.gitIsEnabled(context)) { + preference(R.string.pref_key_ssh_keygen)?.let { + preferenceScreen.removePreference(it) + } + } + + preference(R.string.pref_key_ssh_show_public_key)?.let { + if (AppPreferences.gitIsEnabled(context) && SshKey.canShowSshPublicKey) { + it.setOnPreferenceClickListener { + ShowSshKeyDialogFragment().show(childFragmentManager, "public_key") + true + } + } else { + preferenceScreen.removePreference(it) + } + } + /* Update preferences which depend on multiple others. */ updateRemindersScreen() } diff --git a/app/src/main/java/com/orgzly/android/util/BiometricAuthenticator.kt b/app/src/main/java/com/orgzly/android/util/BiometricAuthenticator.kt new file mode 100644 index 000000000..618f4a1dc --- /dev/null +++ b/app/src/main/java/com/orgzly/android/util/BiometricAuthenticator.kt @@ -0,0 +1,63 @@ +package com.orgzly.android.util + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG +import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import com.orgzly.R +import com.orgzly.android.ui.CommonActivity +import java.io.IOException +import java.util.concurrent.Executor +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +class BiometricAuthenticator(private val callingActivity: CommonActivity) { + private lateinit var biometricManager: BiometricManager + private lateinit var executor: Executor + private lateinit var biometricPrompt: BiometricPrompt + private lateinit var promptInfo: BiometricPrompt.PromptInfo + private val authenticators = BIOMETRIC_STRONG or DEVICE_CREDENTIAL + + suspend fun authenticate(promptText: String) : String? { + + // Initialize BiometricManager for checking biometrics availability + biometricManager = BiometricManager.from(callingActivity) + + // Initialize PromptInfo to set title, subtitle, and authenticators of the biometric prompt + promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle(promptText) + .setAllowedAuthenticators(authenticators) + .build() + + if (biometricManager.canAuthenticate(authenticators) !in listOf( + BiometricManager.BIOMETRIC_SUCCESS, BiometricManager.BIOMETRIC_STATUS_UNKNOWN + )) { + throw IOException( + callingActivity.getString(R.string.biometric_auth_not_available) + ) + } + + return suspendCoroutine { continuation -> + // Initialize BiometricPrompt to setup success & error callbacks of biometric prompt + executor = ContextCompat.getMainExecutor(callingActivity) + biometricPrompt = + BiometricPrompt( + callingActivity, executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + super.onAuthenticationError(errorCode, errString) + continuation.resume(errString.toString()) + } + override fun onAuthenticationSucceeded( + result: BiometricPrompt.AuthenticationResult + ) { + super.onAuthenticationSucceeded(result) + continuation.resume(null) + } + }, + ) + biometricPrompt.authenticate(promptInfo) + } + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_repo_git.xml b/app/src/main/res/layout/activity_repo_git.xml index 74d23d968..ff6c4fadd 100644 --- a/app/src/main/res/layout/activity_repo_git.xml +++ b/app/src/main/res/layout/activity_repo_git.xml @@ -31,7 +31,7 @@ android:id="@+id/activity_repo_git_url" android:layout_width="match_parent" android:layout_height="wrap_content" - android:inputType="text" + android:inputType="textUri" android:imeOptions="actionNext" android:hint="@string/git_url_hint"/> @@ -69,38 +69,6 @@ - - -