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()); + } +}