add UI for certificate trust
This commit is contained in:
parent
786a6c4c2a
commit
9c64f9c24c
|
@ -1,8 +1,8 @@
|
|||
package im.conversations.android.ui;
|
||||
|
||||
import androidx.annotation.IdRes;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.fragment.app.FragmentActivity;
|
||||
import androidx.fragment.app.FragmentManager;
|
||||
import androidx.navigation.NavController;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
|
@ -12,7 +12,7 @@ public final class NavControllers {
|
|||
private NavControllers() {}
|
||||
|
||||
public static NavController findNavController(
|
||||
final AppCompatActivity activity, @IdRes int fragmentId) {
|
||||
final FragmentActivity activity, @IdRes int fragmentId) {
|
||||
final FragmentManager fragmentManager = activity.getSupportFragmentManager();
|
||||
final Fragment fragment = fragmentManager.findFragmentById(fragmentId);
|
||||
if (fragment instanceof NavHostFragment) {
|
||||
|
|
|
@ -55,6 +55,13 @@ public class SetupActivity extends BaseActivity {
|
|||
case ENTER_HOSTNAME:
|
||||
navController.navigate(SetupNavigationDirections.enterHostname());
|
||||
break;
|
||||
case TRUST_CERTIFICATE:
|
||||
final var currentDestination = navController.getCurrentDestination();
|
||||
if (currentDestination == null
|
||||
|| currentDestination.getId() != R.id.certificate) {
|
||||
navController.navigate(SetupNavigationDirections.trustCertificate());
|
||||
}
|
||||
break;
|
||||
case DONE:
|
||||
startActivity(new Intent(this, MainActivity.class));
|
||||
finish();
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -26,8 +26,11 @@ import im.conversations.android.xmpp.ConnectionPool;
|
|||
import im.conversations.android.xmpp.ConnectionState;
|
||||
import im.conversations.android.xmpp.XmppConnection;
|
||||
import im.conversations.android.xmpp.manager.TrustManager;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.CancellationException;
|
||||
import java.util.function.Function;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
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<TrustDecision> trustDecision = new MutableLiveData<>();
|
||||
private final HashMap<ByteBuffer, Boolean> trustDecisions = new HashMap<>();
|
||||
|
||||
private final Function<byte[], ListenableFuture<Boolean>> trustDecisionCallback =
|
||||
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 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.redirection.postValue(new Event<>(Target.TRUST_CERTIFICATE));
|
||||
return settableFuture;
|
||||
};
|
||||
|
||||
|
@ -143,7 +157,7 @@ public class SetupViewModel extends AndroidViewModel {
|
|||
this.xmppAddressError.postValue(getApplication().getString(R.string.invalid_jid));
|
||||
return true;
|
||||
}
|
||||
|
||||
this.trustDecisions.clear();
|
||||
if (account != null) {
|
||||
if (account.address.equals(address)) {
|
||||
this.accountRepository.reconnect(account);
|
||||
|
@ -167,6 +181,51 @@ public class SetupViewModel extends AndroidViewModel {
|
|||
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) {
|
||||
|
||||
// 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();
|
||||
if (optionalTrustManager.isPresent()) {
|
||||
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) {
|
||||
// TODO only when configured for loginAndBind
|
||||
LOGGER.info("Account setup successful");
|
||||
unregisterTrustDecisionCallback();
|
||||
SetupViewModel.this.account = null;
|
||||
redirect(Target.DONE);
|
||||
}
|
||||
|
@ -250,6 +312,10 @@ public class SetupViewModel extends AndroidViewModel {
|
|||
@Override
|
||||
public void onFailure(@NonNull final Throwable throwable) {
|
||||
loading.postValue(false);
|
||||
if (throwable instanceof CancellationException) {
|
||||
LOGGER.info("connection future was cancelled");
|
||||
return;
|
||||
}
|
||||
if (throwable instanceof ConnectionException) {
|
||||
decideNextStep(current, ((ConnectionException) throwable));
|
||||
} else {
|
||||
|
@ -317,6 +383,7 @@ public class SetupViewModel extends AndroidViewModel {
|
|||
this.portError.postValue(getApplication().getString(R.string.invalid));
|
||||
return true;
|
||||
}
|
||||
this.trustDecisions.clear();
|
||||
final boolean directTls = Boolean.FALSE.equals(this.opportunisticTls.getValue());
|
||||
final var connection = new Connection(hostname, port, directTls);
|
||||
final var setConnectionFuture =
|
||||
|
@ -388,15 +455,19 @@ public class SetupViewModel extends AndroidViewModel {
|
|||
return this.redirection;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCleared() {
|
||||
super.onCleared();
|
||||
LOGGER.info("Clearing view model");
|
||||
this.unregisterTrustDecisionCallback();
|
||||
super.onCleared();
|
||||
}
|
||||
|
||||
public enum Target {
|
||||
ENTER_ADDRESS,
|
||||
ENTER_PASSWORD,
|
||||
ENTER_HOSTNAME,
|
||||
|
||||
TRUST_CERTIFICATE,
|
||||
DONE
|
||||
}
|
||||
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
|
||||
package im.conversations.android.util;
|
||||
|
||||
import java.util.function.Supplier;
|
||||
import java.util.function.Function;
|
||||
|
||||
public class PendingItem<T> {
|
||||
|
||||
|
@ -49,10 +49,9 @@ public class PendingItem<T> {
|
|||
return item;
|
||||
}
|
||||
|
||||
public synchronized T peekOrCreate(final Supplier<T> supplier) {
|
||||
if (this.item == null) {
|
||||
this.item = supplier.get();
|
||||
}
|
||||
public synchronized T peekOrSwap(final Function<T, T> swap) {
|
||||
final T item = this.item;
|
||||
this.item = swap.apply(item);
|
||||
return this.item;
|
||||
}
|
||||
|
||||
|
|
|
@ -1882,7 +1882,14 @@ public class XmppConnection implements Runnable {
|
|||
} else if (Arrays.asList(ConnectionState.OFFLINE, ConnectionState.CONNECTING)
|
||||
.contains(state)
|
||||
|| waitOnError) {
|
||||
return this.connectedFuture.peekOrCreate(SettableFuture::create);
|
||||
return this.connectedFuture.peekOrSwap(
|
||||
f -> {
|
||||
if (f == null || f.isDone()) {
|
||||
return SettableFuture.create();
|
||||
} else {
|
||||
return f;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return Futures.immediateFailedFuture(new ConnectionException(state));
|
||||
}
|
||||
|
|
|
@ -68,6 +68,7 @@ public class TrustManager extends AbstractManager implements X509TrustManager {
|
|||
try {
|
||||
decision = Boolean.TRUE.equals(futureDecision.get(10, TimeUnit.SECONDS));
|
||||
} catch (final ExecutionException | InterruptedException | TimeoutException e) {
|
||||
futureDecision.cancel(true);
|
||||
throw new CertificateException(
|
||||
"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) {
|
||||
return Joiner.on(':')
|
||||
.join(Lists.transform(Bytes.asList(bytes), b -> String.format("%02X", b)));
|
||||
return fingerprint(bytes, bytes.length);
|
||||
}
|
||||
|
||||
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(
|
||||
|
|
142
app/src/main/res/layout/fragment_trust_certificate.xml
Normal file
142
app/src/main/res/layout/fragment_trust_certificate.xml
Normal 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>
|
|
@ -28,6 +28,15 @@
|
|||
app:popExitAnim="@anim/slide_to_right"
|
||||
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
|
||||
android:id="@+id/enterHostname"
|
||||
app:destination="@+id/hostname"
|
||||
|
@ -48,6 +57,10 @@
|
|||
android:name="im.conversations.android.ui.fragment.setup.HostnameFragment"
|
||||
tools:layout="@layout/fragment_hostname" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/certificate"
|
||||
android:name="im.conversations.android.ui.fragment.setup.TrustCertificateFragment" />
|
||||
|
||||
<fragment
|
||||
android:id="@+id/password"
|
||||
android:name="im.conversations.android.ui.fragment.setup.PasswordFragment"
|
||||
|
|
|
@ -1042,5 +1042,9 @@
|
|||
<string name="info_required">Info required</string>
|
||||
<string name="invalid">Invalid!</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 don’t know what this means it’s best to go back!</string>
|
||||
<string name="trust_cerficate">Trust certificate</string>
|
||||
|
||||
</resources>
|
||||
|
|
Loading…
Reference in a new issue