From 56e232722bbad2bf30b6f3235ea0b8c995696a36 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Mon, 18 Jul 2022 18:33:38 +0200 Subject: [PATCH 1/8] Git via SSH: Replace Jsch transport with Apache N.B. Requires API >= 26. Apache MINA SSHD is now the standard SSH transport library for JGit. It supports more modern key algorithms than Jsch. I have added a notification prompt to the user upon new or unexpected SSH server host key. I have no idea what I'm doing, but it seems to work. --- app/build.gradle | 6 +- .../java/com/orgzly/android/AppIntent.java | 4 + .../orgzly/android/NotificationChannels.kt | 23 +++ .../android/git/GitSSHKeyTransportSetter.java | 70 ++++++--- .../android/git/SshCredentialsProvider.java | 135 ++++++++++++++++++ .../ui/notifications/Notifications.java | 93 +++++++++++- 6 files changed, 306 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/com/orgzly/android/git/SshCredentialsProvider.java diff --git a/app/build.gradle b/app/build.gradle index 70e52cbd5..52320c394 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -192,7 +192,11 @@ dependencies { implementation "io.github.rburgst:okhttp-digest:$versions.okhttp_digest" 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' + } + } repositories { 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/GitSSHKeyTransportSetter.java b/app/src/main/java/com/orgzly/android/git/GitSSHKeyTransportSetter.java index 8b9872fb1..4d4a690c9 100644 --- a/app/src/main/java/com/orgzly/android/git/GitSSHKeyTransportSetter.java +++ b/app/src/main/java/com/orgzly/android/git/GitSSHKeyTransportSetter.java @@ -1,50 +1,74 @@ package com.orgzly.android.git; -import com.jcraft.jsch.JSch; -import com.jcraft.jsch.JSchException; -import com.jcraft.jsch.Session; +import android.net.Uri; +import android.os.Build; +import androidx.annotation.RequiresApi; + +import com.orgzly.android.App; + +import org.eclipse.jgit.annotations.NonNull; 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.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.util.FS; +import org.eclipse.jgit.transport.sshd.ServerKeyDatabase; +import org.eclipse.jgit.transport.sshd.SshdSessionFactory; + +import java.io.File; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Collections; +import java.util.List; public class GitSSHKeyTransportSetter implements GitTransportSetter { - private String sshKeyPath; - private SshSessionFactory sshSessionFactory; - private TransportConfigCallback configCallback; + private final TransportConfigCallback configCallback; public GitSSHKeyTransportSetter(String pathToSSHKey) { - sshKeyPath = pathToSSHKey; - sshSessionFactory = new JschConfigSessionFactory() { + + SshSessionFactory factory = new SshdSessionFactory(null, null) { + @Override - protected void configure(OpenSshConfig.Host host, Session session ) { - session.setConfig("StrictHostKeyChecking", "no"); + public File getHomeDirectory() { + return App.getAppContext().getFilesDir(); } + @RequiresApi(api = Build.VERSION_CODES.O) @Override - protected JSch createDefaultJSch(FS fs) throws JSchException { - JSch defaultJSch = super.createDefaultJSch(fs); - defaultJSch.addIdentity(sshKeyPath); - return defaultJSch; + protected List getDefaultIdentities(File sshDir) { + return Collections.singletonList(Paths.get(Uri.decode(pathToSSHKey))); + } + + @Override + protected String getDefaultPreferredAuthentications() { + return "publickey"; } - }; - configCallback = new TransportConfigCallback() { @Override - public void configure(Transport transport) { - SshTransport sshTransport = (SshTransport) transport; - sshTransport.setSshSessionFactory(sshSessionFactory); + protected ServerKeyDatabase createServerKeyDatabase(@NonNull File homeDir, + @NonNull File sshDir) { + // We override this method because we want to set "askAboutNewFile" to False. + return new OpenSshServerKeyDatabase(false, + getDefaultKnownHostsFiles(sshDir)); } }; + + SshSessionFactory.setInstance(factory); + + // org.apache.sshd.common.config.keys.IdentityUtils freaks out if user.home is not set + System.setProperty("user.home", App.getAppContext().getFilesDir().toString()); + + configCallback = transport -> { + SshTransport sshTransport = (SshTransport) transport; + sshTransport.setSshSessionFactory(factory); + + }; } public TransportCommand setTransport(TransportCommand tc) { tc.setTransportConfigCallback(configCallback); + tc.setCredentialsProvider(new SshCredentialsProvider()); return tc; } } \ 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/ui/notifications/Notifications.java b/app/src/main/java/com/orgzly/android/ui/notifications/Notifications.java index 321689b12..bf06ef535 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,16 +1,32 @@ 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 android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.os.Build; +import androidx.annotation.RequiresApi; import androidx.core.app.NotificationCompat; import androidx.core.app.RemoteInput; import androidx.core.content.ContextCompat; +import org.eclipse.jgit.internal.transport.sshd.SshdText; +import org.eclipse.jgit.transport.CredentialItem; +import org.eclipse.jgit.transport.URIish; + import com.orgzly.BuildConfig; import com.orgzly.R; import com.orgzly.android.ActionReceiver; @@ -32,6 +48,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 +136,78 @@ 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. + */ + @RequiresApi(api = Build.VERSION_CODES.M) + 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())) + .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()); + } +} From e1fb483d764e21c5e88c5f1df1bd9df9fa692e15 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Tue, 26 Jul 2022 17:23:53 +0200 Subject: [PATCH 2/8] Remove unused methods --- .../orgzly/android/prefs/AppPreferences.java | 27 +------------------ 1 file changed, 1 insertion(+), 26 deletions(-) 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..5661a8545 100644 --- a/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java +++ b/app/src/main/java/com/orgzly/android/prefs/AppPreferences.java @@ -907,32 +907,7 @@ public static boolean gitIsEnabled(Context context) { 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 gitSSHKeyPath(Context context) { - return getStateSharedPreferences(context).getString("pref_key_git_ssh_key_path", null); - } - - public static void gitSSHKeyPath(Context context, String value) { - getStateSharedPreferences(context).edit().putString( - "pref_key_git_ssh_key_path", value).apply(); - } - + public static String defaultRepositoryStorageDirectory(Context context) { File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); return getStringFromSelector( From ac0362260707b312b1a2a4f0f323506fde319e67 Mon Sep 17 00:00:00 2001 From: Victor Andreasson Date: Sat, 30 Jul 2022 00:11:27 +0200 Subject: [PATCH 3/8] Generate the SSH key for Git syncing on the device A generated key can optionally be protected by biometric auth or device credential. This makes it harder to steal, but will obviously not play well with auto-sync. The default key type is EDCSA. ED25519 keys are faster, but not supported natively by the Android key store. The methods currently called when unlocking a ED25519 key do not respect the validity duration setting, which means that the key needs to be unlocked before each use. This may be twice during a sync, if we need to both fetch and push. RSA and EDCSA keys respect the validity duration setting, meaning we leave them unlocked for 15 seconds. A way to speed up Git syncing while requiring auth upon each key use would be to use SSH multiplexing and keep the SSH session open until we push (or decide not to push). I raised the minimum SDK version from 21 to 23. Otherwise we cannot include android-crypto in the manifest. N.B. Much of this code has been taken and re-worked from https://github.com/android-password-store/Android-Password-Store. That project is also GPL-3.0, but I don't know how to properly attribute those authors in the Orgzly code base. --- app/build.gradle | 3 + app/src/main/AndroidManifest.xml | 5 + app/src/main/java/com/orgzly/android/App.java | 3 + .../android/git/GitFileSynchronizer.java | 47 ++- .../git/GitPreferencesFromRepoPrefs.java | 4 +- .../android/git/GitSSHKeyTransportSetter.java | 74 ---- .../android/git/GitSshKeyTransportSetter.kt | 88 +++++ .../java/com/orgzly/android/git/SshKey.kt | 340 ++++++++++++++++++ .../orgzly/android/prefs/AppPreferences.java | 9 + .../orgzly/android/ui/SshKeygenActivity.kt | 149 ++++++++ .../ui/dialogs/ShowSshKeyDialogFragment.kt | 35 ++ .../ui/notifications/Notifications.java | 21 +- .../android/ui/repo/git/GitRepoActivity.kt | 23 +- .../android/ui/settings/SettingsFragment.kt | 27 ++ .../android/util/BiometricAuthenticator.kt | 63 ++++ app/src/main/res/layout/activity_repo_git.xml | 32 -- .../main/res/layout/activity_ssh_keygen.xml | 71 ++++ app/src/main/res/values-cs-rCZ/strings.xml | 1 - app/src/main/res/values-de-rDE/strings.xml | 1 - app/src/main/res/values-es-rES/strings.xml | 1 - app/src/main/res/values-fr-rFR/strings.xml | 1 - app/src/main/res/values-hu-rHU/strings.xml | 1 - app/src/main/res/values-in-rID/strings.xml | 1 - app/src/main/res/values-it-rIT/strings.xml | 1 - app/src/main/res/values-ja-rJP/strings.xml | 1 - app/src/main/res/values-ko-rKR/strings.xml | 1 - app/src/main/res/values-nl-rNL/strings.xml | 1 - app/src/main/res/values-pl-rPL/strings.xml | 1 - app/src/main/res/values-pt-rBR/strings.xml | 1 - app/src/main/res/values-pt-rPT/strings.xml | 1 - app/src/main/res/values-ru-rRU/strings.xml | 1 - app/src/main/res/values-sv-rSE/strings.xml | 1 - app/src/main/res/values-tr-rTR/strings.xml | 1 - app/src/main/res/values-uk-rUA/strings.xml | 1 - app/src/main/res/values-zh-rCN/strings.xml | 1 - app/src/main/res/values-zh-rTW/strings.xml | 1 - app/src/main/res/values/prefs_keys.xml | 4 +- app/src/main/res/values/strings.xml | 34 +- app/src/main/res/xml/prefs_screen_sync.xml | 15 + 39 files changed, 898 insertions(+), 168 deletions(-) delete mode 100644 app/src/main/java/com/orgzly/android/git/GitSSHKeyTransportSetter.java create mode 100644 app/src/main/java/com/orgzly/android/git/GitSshKeyTransportSetter.kt create mode 100644 app/src/main/java/com/orgzly/android/git/SshKey.kt create mode 100644 app/src/main/java/com/orgzly/android/ui/SshKeygenActivity.kt create mode 100644 app/src/main/java/com/orgzly/android/ui/dialogs/ShowSshKeyDialogFragment.kt create mode 100644 app/src/main/java/com/orgzly/android/util/BiometricAuthenticator.kt create mode 100644 app/src/main/res/layout/activity_ssh_keygen.xml diff --git a/app/build.gradle b/app/build.gradle index 52320c394..dc6500cae 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -191,11 +191,14 @@ 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.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' } 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/git/GitFileSynchronizer.java b/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java index d0dcc9773..468941060 100644 --- a/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java +++ b/app/src/main/java/com/orgzly/android/git/GitFileSynchronizer.java @@ -6,6 +6,7 @@ import androidx.annotation.NonNull; +import com.orgzly.R; import com.orgzly.android.App; import com.orgzly.android.util.MiscUtils; @@ -16,6 +17,7 @@ import org.eclipse.jgit.api.Status; import org.eclipse.jgit.api.TransportCommand; import org.eclipse.jgit.api.errors.GitAPIException; +import org.eclipse.jgit.api.errors.TransportException; import org.eclipse.jgit.lib.Constants; import org.eclipse.jgit.lib.ObjectId; import org.eclipse.jgit.lib.Ref; @@ -37,10 +39,12 @@ public class GitFileSynchronizer { private Git git; private GitPreferences preferences; + private Context context; public GitFileSynchronizer(Git g, GitPreferences prefs) { git = g; preferences = prefs; + context = App.getAppContext(); } private GitTransportSetter transportSetter() { @@ -67,12 +71,20 @@ 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) { + throw new IOException(String.format( + "Failed to fetch repo %s: %s", // TODO: move to strings.xml + preferences.remoteUri().toString(), + e.getMessage() + )); + } } public void checkoutSelected() throws GitAPIException { @@ -246,9 +258,21 @@ public void tryPush() { try { pushCommand.call(); } 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 +298,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 +318,7 @@ public void setBranchAndGetLatest() throws IOException { } } } catch (GitAPIException e) { - e.printStackTrace(); - throw new IOException("Failed to update from remote"); + throw new IOException(e.getMessage()); } } 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 4d4a690c9..000000000 --- a/app/src/main/java/com/orgzly/android/git/GitSSHKeyTransportSetter.java +++ /dev/null @@ -1,74 +0,0 @@ -package com.orgzly.android.git; - -import android.net.Uri; -import android.os.Build; - -import androidx.annotation.RequiresApi; - -import com.orgzly.android.App; - -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.sshd.ServerKeyDatabase; -import org.eclipse.jgit.transport.sshd.SshdSessionFactory; - -import java.io.File; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.Collections; -import java.util.List; - -public class GitSSHKeyTransportSetter implements GitTransportSetter { - private final TransportConfigCallback configCallback; - - public GitSSHKeyTransportSetter(String pathToSSHKey) { - - SshSessionFactory factory = new SshdSessionFactory(null, null) { - - @Override - public File getHomeDirectory() { - return App.getAppContext().getFilesDir(); - } - - @RequiresApi(api = Build.VERSION_CODES.O) - @Override - protected List getDefaultIdentities(File sshDir) { - return Collections.singletonList(Paths.get(Uri.decode(pathToSSHKey))); - } - - @Override - protected String getDefaultPreferredAuthentications() { - return "publickey"; - } - - @Override - protected ServerKeyDatabase createServerKeyDatabase(@NonNull File homeDir, - @NonNull File sshDir) { - // We override this method because we want to set "askAboutNewFile" to False. - return new OpenSshServerKeyDatabase(false, - getDefaultKnownHostsFiles(sshDir)); - } - }; - - SshSessionFactory.setInstance(factory); - - // org.apache.sshd.common.config.keys.IdentityUtils freaks out if user.home is not set - System.setProperty("user.home", App.getAppContext().getFilesDir().toString()); - - configCallback = transport -> { - SshTransport sshTransport = (SshTransport) transport; - sshTransport.setSshSessionFactory(factory); - - }; - } - - public TransportCommand setTransport(TransportCommand tc) { - tc.setTransportConfigCallback(configCallback); - tc.setCredentialsProvider(new SshCredentialsProvider()); - 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/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 5661a8545..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,6 +902,15 @@ public static void dropboxToken(Context context, String value) { * Git Sync */ + public static String gitSshKeyType(Context context) { + return getDefaultSharedPreferences(context).getString( + "pref_key_git_ssh_key_type", null); + } + + public static void gitSshKeyType(Context context, String value) { + getDefaultSharedPreferences(context).edit().putString("pref_key_git_ssh_key_type", value).apply(); + } + public static boolean gitIsEnabled(Context context) { return getDefaultSharedPreferences(context).getBoolean( context.getResources().getString(R.string.pref_key_git_is_enabled), 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 bf06ef535..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 @@ -12,21 +12,25 @@ 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; import android.os.Build; -import androidx.annotation.RequiresApi; import androidx.core.app.NotificationCompat; import androidx.core.app.RemoteInput; import androidx.core.content.ContextCompat; -import org.eclipse.jgit.internal.transport.sshd.SshdText; -import org.eclipse.jgit.transport.CredentialItem; -import org.eclipse.jgit.transport.URIish; - import com.orgzly.BuildConfig; import com.orgzly.R; import com.orgzly.android.ActionReceiver; @@ -40,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(); @@ -143,7 +151,6 @@ public static void cancelNewNoteNotification(Context context) { Presents either two or three choices to the user. The selected action is passed back to the SshCredentialsProvider via a broadcast receiver. */ - @RequiresApi(api = Build.VERSION_CODES.M) public static void showSshRemoteHostKeyPrompt(Context context, URIish uri, CredentialItem... items) { // Parse CredentialItems List messages = new ArrayList<>(); @@ -169,7 +176,7 @@ public static void showSshRemoteHostKeyPrompt(Context context, URIish uri, Crede .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())) + .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() 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..c893ec5c2 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 @@ -21,7 +21,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 @@ -79,10 +79,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,10 +102,6 @@ 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) @@ -190,14 +182,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 @@ -368,8 +356,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 +416,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 +489,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..580399572 100644 --- a/app/src/main/res/layout/activity_repo_git.xml +++ b/app/src/main/res/layout/activity_repo_git.xml @@ -69,38 +69,6 @@ - - -