add UI for certificate trust

This commit is contained in:
Daniel Gultsch 2023-03-01 22:02:04 +01:00
parent 786a6c4c2a
commit 9c64f9c24c
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
10 changed files with 306 additions and 13 deletions

View file

@ -1,8 +1,8 @@
package im.conversations.android.ui; package im.conversations.android.ui;
import androidx.annotation.IdRes; import androidx.annotation.IdRes;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentManager;
import androidx.navigation.NavController; import androidx.navigation.NavController;
import androidx.navigation.fragment.NavHostFragment; import androidx.navigation.fragment.NavHostFragment;
@ -12,7 +12,7 @@ public final class NavControllers {
private NavControllers() {} private NavControllers() {}
public static NavController findNavController( public static NavController findNavController(
final AppCompatActivity activity, @IdRes int fragmentId) { final FragmentActivity activity, @IdRes int fragmentId) {
final FragmentManager fragmentManager = activity.getSupportFragmentManager(); final FragmentManager fragmentManager = activity.getSupportFragmentManager();
final Fragment fragment = fragmentManager.findFragmentById(fragmentId); final Fragment fragment = fragmentManager.findFragmentById(fragmentId);
if (fragment instanceof NavHostFragment) { if (fragment instanceof NavHostFragment) {

View file

@ -55,6 +55,13 @@ public class SetupActivity extends BaseActivity {
case ENTER_HOSTNAME: case ENTER_HOSTNAME:
navController.navigate(SetupNavigationDirections.enterHostname()); navController.navigate(SetupNavigationDirections.enterHostname());
break; break;
case TRUST_CERTIFICATE:
final var currentDestination = navController.getCurrentDestination();
if (currentDestination == null
|| currentDestination.getId() != R.id.certificate) {
navController.navigate(SetupNavigationDirections.trustCertificate());
}
break;
case DONE: case DONE:
startActivity(new Intent(this, MainActivity.class)); startActivity(new Intent(this, MainActivity.class));
finish(); finish();

View file

@ -0,0 +1,40 @@
package im.conversations.android.ui.fragment.setup;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.databinding.DataBindingUtil;
import im.conversations.android.R;
import im.conversations.android.databinding.FragmentTrustCertificateBinding;
import im.conversations.android.ui.NavControllers;
public class TrustCertificateFragment extends AbstractSetupFragment {
@Override
public View onCreateView(
@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
super.onCreateView(inflater, container, savedInstanceState);
FragmentTrustCertificateBinding binding =
DataBindingUtil.inflate(
inflater, R.layout.fragment_trust_certificate, container, false);
binding.setSetupViewModel(setupViewModel);
binding.setLifecycleOwner(getViewLifecycleOwner());
requireActivity()
.getOnBackPressedDispatcher()
.addCallback(
getViewLifecycleOwner(),
new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
setupViewModel.rejectTrustDecision();
NavControllers.findNavController(
requireActivity(), R.id.nav_host_fragment)
.navigateUp();
}
});
return binding.getRoot();
}
}

View file

@ -26,8 +26,11 @@ import im.conversations.android.xmpp.ConnectionPool;
import im.conversations.android.xmpp.ConnectionState; import im.conversations.android.xmpp.ConnectionState;
import im.conversations.android.xmpp.XmppConnection; import im.conversations.android.xmpp.XmppConnection;
import im.conversations.android.xmpp.manager.TrustManager; import im.conversations.android.xmpp.manager.TrustManager;
import java.nio.ByteBuffer;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap;
import java.util.Locale; import java.util.Locale;
import java.util.concurrent.CancellationException;
import java.util.function.Function; import java.util.function.Function;
import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNull;
import org.jxmpp.jid.BareJid; import org.jxmpp.jid.BareJid;
@ -56,13 +59,24 @@ public class SetupViewModel extends AndroidViewModel {
private final MutableLiveData<Event<Target>> redirection = new MutableLiveData<>(); private final MutableLiveData<Event<Target>> redirection = new MutableLiveData<>();
private final MutableLiveData<TrustDecision> trustDecision = new MutableLiveData<>(); private final MutableLiveData<TrustDecision> trustDecision = new MutableLiveData<>();
private final HashMap<ByteBuffer, Boolean> trustDecisions = new HashMap<>();
private final Function<byte[], ListenableFuture<Boolean>> trustDecisionCallback = private final Function<byte[], ListenableFuture<Boolean>> trustDecisionCallback =
fingerprint -> { fingerprint -> {
final var decision = this.trustDecisions.get(ByteBuffer.wrap(fingerprint));
if (decision != null) {
LOGGER.info("Using previous trust decision ({})", decision);
return Futures.immediateFuture(decision);
}
LOGGER.info("Trust decision arrived in UI");
final SettableFuture<Boolean> settableFuture = SettableFuture.create(); final SettableFuture<Boolean> settableFuture = SettableFuture.create();
final var trustDecision = new TrustDecision(fingerprint, settableFuture); final var trustDecision = new TrustDecision(fingerprint, settableFuture);
LOGGER.debug("posting trust decision"); final var currentOperation = this.currentOperation;
if (currentOperation != null) {
currentOperation.cancel(false);
}
this.trustDecision.postValue(trustDecision); this.trustDecision.postValue(trustDecision);
this.redirection.postValue(new Event<>(Target.TRUST_CERTIFICATE));
return settableFuture; return settableFuture;
}; };
@ -143,7 +157,7 @@ public class SetupViewModel extends AndroidViewModel {
this.xmppAddressError.postValue(getApplication().getString(R.string.invalid_jid)); this.xmppAddressError.postValue(getApplication().getString(R.string.invalid_jid));
return true; return true;
} }
this.trustDecisions.clear();
if (account != null) { if (account != null) {
if (account.address.equals(address)) { if (account.address.equals(address)) {
this.accountRepository.reconnect(account); this.accountRepository.reconnect(account);
@ -167,6 +181,51 @@ public class SetupViewModel extends AndroidViewModel {
return true; return true;
} }
public boolean trustCertificate() {
final var trustDecision = this.trustDecision.getValue();
final var account = this.account;
if (trustDecision == null || account == null) {
// TODO navigate back to sign in or show error?
return true;
}
LOGGER.info(
"trying to commit trust for fingerprint {}",
TrustManager.fingerprint(trustDecision.fingerprint));
// in case the UI interface hook gets called again before this gets written to DB
this.trustDecisions.put(ByteBuffer.wrap(trustDecision.fingerprint), true);
if (trustDecision.decision.isDone()) {
ConnectionPool.getInstance(getApplication()).reconnect(account);
LOGGER.info("it was already done. we should reconnect");
}
trustDecision.decision.set(true);
decideNextStep(Target.TRUST_CERTIFICATE, account);
return true;
}
public void rejectTrustDecision() {
final var trustDecision = this.trustDecision.getValue();
if (trustDecision == null) {
return;
}
LOGGER.info(
"Rejecting trust decision for {}",
TrustManager.fingerprint(trustDecision.fingerprint));
trustDecision.decision.set(false);
this.trustDecisions.put(ByteBuffer.wrap(trustDecision.fingerprint), false);
}
public LiveData<String> getFingerprint() {
return Transformations.map(
this.trustDecision,
td -> {
if (td == null) {
return null;
} else {
return TrustManager.fingerprint(td.fingerprint, 8);
}
});
}
private void createAccount(final BareJid address) { private void createAccount(final BareJid address) {
// if the user hasn't entered anything we want this to be null so we don't store credentials // if the user hasn't entered anything we want this to be null so we don't store credentials
@ -229,6 +288,8 @@ public class SetupViewModel extends AndroidViewModel {
final var optionalTrustManager = getTrustManager(); final var optionalTrustManager = getTrustManager();
if (optionalTrustManager.isPresent()) { if (optionalTrustManager.isPresent()) {
optionalTrustManager.get().removeUserInterfaceCallback(this.trustDecisionCallback); optionalTrustManager.get().removeUserInterfaceCallback(this.trustDecisionCallback);
} else {
LOGGER.warn("No trust manager found");
} }
} }
@ -243,6 +304,7 @@ public class SetupViewModel extends AndroidViewModel {
public void onSuccess(final XmppConnection result) { public void onSuccess(final XmppConnection result) {
// TODO only when configured for loginAndBind // TODO only when configured for loginAndBind
LOGGER.info("Account setup successful"); LOGGER.info("Account setup successful");
unregisterTrustDecisionCallback();
SetupViewModel.this.account = null; SetupViewModel.this.account = null;
redirect(Target.DONE); redirect(Target.DONE);
} }
@ -250,6 +312,10 @@ public class SetupViewModel extends AndroidViewModel {
@Override @Override
public void onFailure(@NonNull final Throwable throwable) { public void onFailure(@NonNull final Throwable throwable) {
loading.postValue(false); loading.postValue(false);
if (throwable instanceof CancellationException) {
LOGGER.info("connection future was cancelled");
return;
}
if (throwable instanceof ConnectionException) { if (throwable instanceof ConnectionException) {
decideNextStep(current, ((ConnectionException) throwable)); decideNextStep(current, ((ConnectionException) throwable));
} else { } else {
@ -317,6 +383,7 @@ public class SetupViewModel extends AndroidViewModel {
this.portError.postValue(getApplication().getString(R.string.invalid)); this.portError.postValue(getApplication().getString(R.string.invalid));
return true; return true;
} }
this.trustDecisions.clear();
final boolean directTls = Boolean.FALSE.equals(this.opportunisticTls.getValue()); final boolean directTls = Boolean.FALSE.equals(this.opportunisticTls.getValue());
final var connection = new Connection(hostname, port, directTls); final var connection = new Connection(hostname, port, directTls);
final var setConnectionFuture = final var setConnectionFuture =
@ -388,15 +455,19 @@ public class SetupViewModel extends AndroidViewModel {
return this.redirection; return this.redirection;
} }
@Override
public void onCleared() { public void onCleared() {
super.onCleared(); LOGGER.info("Clearing view model");
this.unregisterTrustDecisionCallback(); this.unregisterTrustDecisionCallback();
super.onCleared();
} }
public enum Target { public enum Target {
ENTER_ADDRESS, ENTER_ADDRESS,
ENTER_PASSWORD, ENTER_PASSWORD,
ENTER_HOSTNAME, ENTER_HOSTNAME,
TRUST_CERTIFICATE,
DONE DONE
} }

View file

@ -29,7 +29,7 @@
package im.conversations.android.util; package im.conversations.android.util;
import java.util.function.Supplier; import java.util.function.Function;
public class PendingItem<T> { public class PendingItem<T> {
@ -49,10 +49,9 @@ public class PendingItem<T> {
return item; return item;
} }
public synchronized T peekOrCreate(final Supplier<T> supplier) { public synchronized T peekOrSwap(final Function<T, T> swap) {
if (this.item == null) { final T item = this.item;
this.item = supplier.get(); this.item = swap.apply(item);
}
return this.item; return this.item;
} }

View file

@ -1882,7 +1882,14 @@ public class XmppConnection implements Runnable {
} else if (Arrays.asList(ConnectionState.OFFLINE, ConnectionState.CONNECTING) } else if (Arrays.asList(ConnectionState.OFFLINE, ConnectionState.CONNECTING)
.contains(state) .contains(state)
|| waitOnError) { || waitOnError) {
return this.connectedFuture.peekOrCreate(SettableFuture::create); return this.connectedFuture.peekOrSwap(
f -> {
if (f == null || f.isDone()) {
return SettableFuture.create();
} else {
return f;
}
});
} else { } else {
return Futures.immediateFailedFuture(new ConnectionException(state)); return Futures.immediateFailedFuture(new ConnectionException(state));
} }

View file

@ -68,6 +68,7 @@ public class TrustManager extends AbstractManager implements X509TrustManager {
try { try {
decision = Boolean.TRUE.equals(futureDecision.get(10, TimeUnit.SECONDS)); decision = Boolean.TRUE.equals(futureDecision.get(10, TimeUnit.SECONDS));
} catch (final ExecutionException | InterruptedException | TimeoutException e) { } catch (final ExecutionException | InterruptedException | TimeoutException e) {
futureDecision.cancel(true);
throw new CertificateException( throw new CertificateException(
"Timeout waiting for user response", Throwables.getRootCause(e)); "Timeout waiting for user response", Throwables.getRootCause(e));
} }
@ -97,8 +98,17 @@ public class TrustManager extends AbstractManager implements X509TrustManager {
} }
public static String fingerprint(final byte[] bytes) { public static String fingerprint(final byte[] bytes) {
return Joiner.on(':') return fingerprint(bytes, bytes.length);
.join(Lists.transform(Bytes.asList(bytes), b -> String.format("%02X", b))); }
public static String fingerprint(final byte[] bytes, final int segments) {
return Joiner.on('\n')
.join(
Lists.transform(
Lists.transform(
Lists.partition(Bytes.asList(bytes), segments),
s -> Lists.transform(s, b -> String.format("%02X", b))),
hex -> Joiner.on(':').join(hex)));
} }
public void setUserInterfaceCallback( public void setUserInterfaceCallback(

View file

@ -0,0 +1,142 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2019-2023 Daniel Gultsch
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:liftOnScroll="false"
app:elevation="0dp">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/material_toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
app:title="@string/app_name"
app:menu="@menu/activity_setup"
app:titleTextColor="?colorPrimary"
app:titleCentered="true" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="@{setupViewModel.isLoading() ? View.VISIBLE : View.INVISIBLE}" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.core.widget.NestedScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:minHeight="@dimen/setup_main_min_height"
android:orientation="vertical"
android:padding="@dimen/setup_screen_padding">
<TextView
android:id="@+id/header"
android:textColor="?colorError"
android:textAppearance="?textAppearanceTitleMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_marginTop="@dimen/setup_screen_top_margin"
android:text="@string/potential_security_risk_ahead" />
<TextView
android:id="@+id/warning"
android:textAppearance="?textAppearanceBodyMedium"
android:textColor="?colorError"
android:layout_below="@+id/header"
android:layout_width="wrap_content"
android:layout_marginTop="12sp"
android:layout_centerHorizontal="true"
android:text="@string/trust_certificate_warning"
android:layout_height="wrap_content"/>
<TextView
android:textAppearance="?textAppearanceBodyMedium"
android:layout_below="@+id/warning"
android:id="@+id/instructions"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12sp"
android:layout_centerHorizontal="true"
android:text="@string/trust_certificate_instructions" />
<LinearLayout
android:layout_marginVertical="24dp"
android:id="@+id/password_input_layout"
android:layout_width="488dp"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_below="@+id/instructions"
android:gravity="center_horizontal"
android:layout_gravity="center_horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?textAppearanceBodyLarge"
android:textColor="?colorTertiary"
android:typeface="monospace"
android:text="@{setupViewModel.fingerprint}"
tools:text="2B:F0:58:07:E1:A9:7F:4E\nD5:90:90:98:D5:61:39:12\n90:21:8D:83:C8:52:CB:82\n24:28:B5:43:2F:AE:81:44" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/next"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentBottom="true"
android:enabled="@{!setupViewModel.isLoading()}"
android:onClick="@{(v) -> setupViewModel.trustCertificate()}"
android:text="@string/trust_cerficate" />
</RelativeLayout>
</androidx.core.widget.NestedScrollView>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<data>
<import type="android.view.View" />
<variable
name="setupViewModel"
type="im.conversations.android.ui.model.SetupViewModel" />
</data>
</layout>

View file

@ -28,6 +28,15 @@
app:popExitAnim="@anim/slide_to_right" app:popExitAnim="@anim/slide_to_right"
app:popUpTo="@+id/hostname" /> app:popUpTo="@+id/hostname" />
<action
android:id="@+id/trustCertificate"
app:destination="@+id/certificate"
app:enterAnim="@anim/slide_from_right"
app:exitAnim="@anim/slide_to_left"
app:popEnterAnim="@anim/slide_from_left"
app:popExitAnim="@anim/slide_to_right"
app:popUpTo="@+id/hostname" />
<action <action
android:id="@+id/enterHostname" android:id="@+id/enterHostname"
app:destination="@+id/hostname" app:destination="@+id/hostname"
@ -48,6 +57,10 @@
android:name="im.conversations.android.ui.fragment.setup.HostnameFragment" android:name="im.conversations.android.ui.fragment.setup.HostnameFragment"
tools:layout="@layout/fragment_hostname" /> tools:layout="@layout/fragment_hostname" />
<fragment
android:id="@+id/certificate"
android:name="im.conversations.android.ui.fragment.setup.TrustCertificateFragment" />
<fragment <fragment
android:id="@+id/password" android:id="@+id/password"
android:name="im.conversations.android.ui.fragment.setup.PasswordFragment" android:name="im.conversations.android.ui.fragment.setup.PasswordFragment"

View file

@ -1042,5 +1042,9 @@
<string name="info_required">Info required</string> <string name="info_required">Info required</string>
<string name="invalid">Invalid!</string> <string name="invalid">Invalid!</string>
<string name="certificate_login">Certificate login</string> <string name="certificate_login">Certificate login</string>
<string name="potential_security_risk_ahead">Potential security risk ahead</string>
<string name="trust_certificate_instructions">To continue compare this SHA-256 fingerprint with that of the server certificate</string>
<string name="trust_certificate_warning">The server certificate is not trustworthy. If you dont know what this means its best to go back!</string>
<string name="trust_cerficate">Trust certificate</string>
</resources> </resources>