diff --git a/build.gradle b/build.gradle
index c9c8eabb7..169129082 100644
--- a/build.gradle
+++ b/build.gradle
@@ -71,6 +71,7 @@ dependencies {
implementation 'com.google.guava:guava:32.1.3-android'
implementation 'io.michaelrocks:libphonenumber-android:8.13.17'
implementation 'im.conversations.webrtc:webrtc-android:119.0.0'
+ implementation 'org.jitsi:org.otr4j:0.23'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation "androidx.recyclerview:recyclerview:1.2.1"
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index d848e4027..8c9a0b9b6 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -299,6 +299,11 @@
+
bookmarks = new HashMap<>();
private Presence.Status presenceStatus;
private String presenceStatusMessage;
@@ -535,6 +544,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
}
public void initAccountServices(final XmppConnectionService context) {
+ this.mOtrService = new OtrService(context, this);
this.axolotlService = new AxolotlService(this, context);
this.pgpDecryptionService = new PgpDecryptionService(context);
if (xmppConnection != null) {
@@ -542,6 +552,10 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
}
}
+ public OtrService getOtrService() {
+ return this.mOtrService;
+ }
+
public PgpDecryptionService getPgpDecryptionService() {
return this.pgpDecryptionService;
}
@@ -554,6 +568,27 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
this.xmppConnection = connection;
}
+ public String getOtrFingerprint() {
+ if (this.otrFingerprint == null) {
+ try {
+ if (this.mOtrService == null) {
+ return null;
+ }
+ final PublicKey publicKey = this.mOtrService.getPublicKey();
+ if (publicKey == null || !(publicKey instanceof DSAPublicKey)) {
+ return null;
+ }
+ this.otrFingerprint = new OtrCryptoEngineImpl().getFingerprint(publicKey).toLowerCase(Locale.US);
+ return this.otrFingerprint;
+ } catch (final OtrCryptoException ignored) {
+ return null;
+ }
+ } else {
+ return this.otrFingerprint;
+ }
+ }
+
+
public String getRosterVersion() {
if (this.rosterVersion == null) {
return "";
@@ -721,6 +756,10 @@ public class Account extends AbstractEntity implements AvatarService.Avatarable
private List getFingerprints() {
ArrayList fingerprints = new ArrayList<>();
+ final String otr = this.getOtrFingerprint();
+ if (otr != null) {
+ fingerprints.add(new XmppUri.Fingerprint(XmppUri.FingerprintType.OTR, otr));
+ }
if (axolotlService == null) {
return fingerprints;
}
diff --git a/src/main/java/eu/siacs/conversations/entities/Contact.java b/src/main/java/eu/siacs/conversations/entities/Contact.java
index 6b0554240..53f0134bf 100644
--- a/src/main/java/eu/siacs/conversations/entities/Contact.java
+++ b/src/main/java/eu/siacs/conversations/entities/Contact.java
@@ -348,6 +348,47 @@ public class Contact implements ListItem, Blockable {
return groups;
}
+ public ArrayList getOtrFingerprints() {
+ synchronized (this.keys) {
+ final ArrayList fingerprints = new ArrayList();
+ try {
+ if (this.keys.has("otr_fingerprints")) {
+ final JSONArray prints = this.keys.getJSONArray("otr_fingerprints");
+ for (int i = 0; i < prints.length(); ++i) {
+ final String print = prints.isNull(i) ? null : prints.getString(i);
+ if (print != null && !print.isEmpty()) {
+ fingerprints.add(prints.getString(i).toLowerCase(Locale.US));
+ }
+ }
+ }
+ } catch (final JSONException ignored) {
+
+ }
+ return fingerprints;
+ }
+ }
+
+ public boolean addOtrFingerprint(String print) {
+ synchronized (this.keys) {
+ if (getOtrFingerprints().contains(print)) {
+ return false;
+ }
+ try {
+ JSONArray fingerprints;
+ if (!this.keys.has("otr_fingerprints")) {
+ fingerprints = new JSONArray();
+ } else {
+ fingerprints = this.keys.getJSONArray("otr_fingerprints");
+ }
+ fingerprints.put(print);
+ this.keys.put("otr_fingerprints", fingerprints);
+ return true;
+ } catch (final JSONException ignored) {
+ return false;
+ }
+ }
+ }
+
public long getPgpKeyId() {
synchronized (this.keys) {
if (this.keys.has("pgp_keyid")) {
diff --git a/src/main/java/eu/siacs/conversations/entities/Conversation.java b/src/main/java/eu/siacs/conversations/entities/Conversation.java
index e3c147320..da60498d3 100644
--- a/src/main/java/eu/siacs/conversations/entities/Conversation.java
+++ b/src/main/java/eu/siacs/conversations/entities/Conversation.java
@@ -71,6 +71,7 @@ import org.json.JSONException;
import org.json.JSONObject;
import java.lang.ref.WeakReference;
+import java.security.interfaces.DSAPublicKey;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
@@ -81,6 +82,7 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.ListIterator;
+import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.Timer;
@@ -132,6 +134,12 @@ import me.saket.bettermovementmethod.BetterLinkMovementMethod;
import static eu.siacs.conversations.entities.Bookmark.printableValue;
+import net.java.otr4j.OtrException;
+import net.java.otr4j.crypto.OtrCryptoException;
+import net.java.otr4j.session.SessionID;
+import net.java.otr4j.session.SessionImpl;
+import net.java.otr4j.session.SessionStatus;
+
public class Conversation extends AbstractEntity implements Blockable, Comparable, Conversational, AvatarService.Avatarable {
public static final String TABLENAME = "conversations";
@@ -180,10 +188,15 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
private int mode;
private JSONObject attributes;
private Jid nextCounterpart;
+ private transient SessionImpl otrSession;
+ private transient String otrFingerprint = null;
+ private Smp mSmp = new Smp();
private transient MucOptions mucOptions = null;
+ private byte[] symmetricKey;
private boolean messagesLeftOnServer = true;
private ChatState mOutgoingChatState = Config.DEFAULT_CHAT_STATE;
private ChatState mIncomingChatState = Config.DEFAULT_CHAT_STATE;
+ private String mLastReceivedOtrMessageId = null;
private String mFirstMamReference = null;
protected Message replyTo = null;
protected int mCurrentTab = -1;
@@ -490,6 +503,17 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
}
}
+ public void findUnsentMessagesWithEncryption(int encryptionType, OnMessageFound onMessageFound) {
+ synchronized (this.messages) {
+ for (Message message : this.messages) {
+ if ((message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_WAITING)
+ && (message.getEncryption() == encryptionType)) {
+ onMessageFound.onMessageFound(message);
+ }
+ }
+ }
+ }
+
public void findUnsentTextMessages(OnMessageFound onMessageFound) {
final ArrayList results = new ArrayList<>();
synchronized (this.messages) {
@@ -662,6 +686,14 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
return getContact().getBlockedJid();
}
+ public String getLastReceivedOtrMessageId() {
+ return this.mLastReceivedOtrMessageId;
+ }
+
+ public void setLastReceivedOtrMessageId(String id) {
+ this.mLastReceivedOtrMessageId = id;
+ }
+
public int countMessages() {
synchronized (this.messages) {
return this.messages.size();
@@ -923,6 +955,124 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
this.mode = mode;
}
+ public SessionImpl startOtrSession(String presence, boolean sendStart) {
+ if (this.otrSession != null) {
+ return this.otrSession;
+ } else {
+ final SessionID sessionId = new SessionID(this.getJid().asBareJid().toString(),
+ presence,
+ "xmpp");
+ this.otrSession = new SessionImpl(sessionId, getAccount().getOtrService());
+ try {
+ if (sendStart) {
+ this.otrSession.startSession();
+ return this.otrSession;
+ }
+ return this.otrSession;
+ } catch (OtrException e) {
+ return null;
+ }
+ }
+
+ }
+
+ public SessionImpl getOtrSession() {
+ return this.otrSession;
+ }
+
+ public void resetOtrSession() {
+ this.otrFingerprint = null;
+ this.otrSession = null;
+ this.mSmp.hint = null;
+ this.mSmp.secret = null;
+ this.mSmp.status = Smp.STATUS_NONE;
+ }
+
+ public Smp smp() {
+ return mSmp;
+ }
+
+ public boolean startOtrIfNeeded() {
+ if (this.otrSession != null && this.otrSession.getSessionStatus() != SessionStatus.ENCRYPTED) {
+ try {
+ this.otrSession.startSession();
+ return true;
+ } catch (OtrException e) {
+ this.resetOtrSession();
+ return false;
+ }
+ } else {
+ return true;
+ }
+ }
+
+ public boolean endOtrIfNeeded() {
+ if (this.otrSession != null) {
+ if (this.otrSession.getSessionStatus() == SessionStatus.ENCRYPTED) {
+ try {
+ this.otrSession.endSession();
+ this.resetOtrSession();
+ return true;
+ } catch (OtrException e) {
+ this.resetOtrSession();
+ return false;
+ }
+ } else {
+ this.resetOtrSession();
+ return false;
+ }
+ } else {
+ return false;
+ }
+ }
+
+ public boolean hasValidOtrSession() {
+ return this.otrSession != null;
+ }
+
+ public synchronized String getOtrFingerprint() {
+ if (this.otrFingerprint == null) {
+ try {
+ if (getOtrSession() == null || getOtrSession().getSessionStatus() != SessionStatus.ENCRYPTED) {
+ return null;
+ }
+ DSAPublicKey remotePubKey = (DSAPublicKey) getOtrSession().getRemotePublicKey();
+ this.otrFingerprint = getAccount().getOtrService().getFingerprint(remotePubKey).toLowerCase(Locale.US);
+ } catch (final OtrCryptoException ignored) {
+ return null;
+ } catch (final UnsupportedOperationException ignored) {
+ return null;
+ }
+ }
+ return this.otrFingerprint;
+ }
+
+ public boolean verifyOtrFingerprint() {
+ final String fingerprint = getOtrFingerprint();
+ if (fingerprint != null) {
+ getContact().addOtrFingerprint(fingerprint);
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ public boolean isOtrFingerprintVerified() {
+ return getContact().getOtrFingerprints().contains(getOtrFingerprint());
+ }
+
+ public class Smp {
+ public static final int STATUS_NONE = 0;
+ public static final int STATUS_CONTACT_REQUESTED = 1;
+ public static final int STATUS_WE_REQUESTED = 2;
+ public static final int STATUS_FAILED = 3;
+ public static final int STATUS_VERIFIED = 4;
+
+ public String secret = null;
+ public String hint = null;
+ public int status = 0;
+ }
+
/**
* short for is Private and Non-anonymous
*/
@@ -964,7 +1114,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
}
public int getNextEncryption() {
- if (!Config.supportOmemo() && !Config.supportOpenPgp()) {
+ if (!Config.supportOmemo() && !Config.supportOpenPgp() && !Config.supportOtr()) {
return Message.ENCRYPTION_NONE;
}
if (OmemoSetting.isAlways()) {
@@ -993,6 +1143,10 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
return nextMessage == null ? "" : nextMessage;
}
+ public boolean smpRequested() {
+ return smp().status == Smp.STATUS_CONTACT_REQUESTED;
+ }
+
public @Nullable
Draft getDraft() {
long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0);
@@ -1015,6 +1169,14 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
return changed;
}
+ public void setSymmetricKey(byte[] key) {
+ this.symmetricKey = key;
+ }
+
+ public byte[] getSymmetricKey() {
+ return this.symmetricKey;
+ }
+
public Bookmark getBookmark() {
return this.account.getBookmark(this.contactJid);
}
diff --git a/src/main/java/eu/siacs/conversations/entities/Transferable.java b/src/main/java/eu/siacs/conversations/entities/Transferable.java
index 5c833f603..58297d26a 100644
--- a/src/main/java/eu/siacs/conversations/entities/Transferable.java
+++ b/src/main/java/eu/siacs/conversations/entities/Transferable.java
@@ -6,7 +6,7 @@ import java.util.List;
public interface Transferable {
List VALID_IMAGE_EXTENSIONS = Arrays.asList("webp", "jpeg", "jpg", "png", "jpe");
- List VALID_CRYPTO_EXTENSIONS = Arrays.asList("pgp", "gpg");
+ List VALID_CRYPTO_EXTENSIONS = Arrays.asList("pgp", "gpg", "otr");
int STATUS_UNKNOWN = 0x200;
int STATUS_CHECKING = 0x201;
diff --git a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java
index 706b50043..1088427fb 100644
--- a/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java
+++ b/src/main/java/eu/siacs/conversations/generator/AbstractGenerator.java
@@ -57,6 +57,9 @@ public abstract class AbstractGenerator {
private final String[] PRIVACY_SENSITIVE = {
"urn:xmpp:time" //XEP-0202: Entity Time leaks time zone
};
+ private final String[] OTR = {
+ "urn:xmpp:otr:0"
+ };
private final String[] VOIP_NAMESPACES = {
Namespace.JINGLE_TRANSPORT_ICE_UDP,
Namespace.JINGLE_FEATURE_AUDIO,
@@ -125,6 +128,9 @@ public abstract class AbstractGenerator {
features.addAll(Arrays.asList(PRIVACY_SENSITIVE));
features.addAll(Arrays.asList(VOIP_NAMESPACES));
}
+ if (Config.supportOtr()) {
+ features.addAll(Arrays.asList(OTR));
+ }
if (mXmppConnectionService.broadcastLastActivity()) {
features.add(Namespace.IDLE);
}
diff --git a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java b/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java
index 944e36d15..ddbb6d71c 100644
--- a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java
+++ b/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java
@@ -1,5 +1,8 @@
package eu.siacs.conversations.generator;
+import net.java.otr4j.OtrException;
+import net.java.otr4j.session.Session;
+
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
@@ -24,6 +27,7 @@ import eu.siacs.conversations.xmpp.jingle.Media;
import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
public class MessageGenerator extends AbstractGenerator {
+ public static final String OTR_FALLBACK_MESSAGE = "I would like to start a private (OTR encrypted) conversation but your client doesn’t seem to support that";
private static final String OMEMO_FALLBACK_MESSAGE = "I sent you an OMEMO encrypted message but your client doesn’t seem to support that. Find more information on https://conversations.im/omemo";
private static final String PGP_FALLBACK_MESSAGE = "I sent you a PGP encrypted message but your client doesn’t seem to support that.";
@@ -102,6 +106,36 @@ public class MessageGenerator extends AbstractGenerator {
return packet;
}
+ public static void addMessageHints(MessagePacket packet) {
+ packet.addChild("private", "urn:xmpp:carbons:2");
+ packet.addChild("no-copy", "urn:xmpp:hints");
+ packet.addChild("no-permanent-store", "urn:xmpp:hints");
+ packet.addChild("no-permanent-storage", "urn:xmpp:hints"); //do not copy this. this is wrong. it is *store*
+ }
+
+ public MessagePacket generateOtrChat(Message message) {
+ Conversation conversation = (Conversation) message.getConversation();
+ Session otrSession = conversation.getOtrSession();
+ if (otrSession == null) {
+ return null;
+ }
+ MessagePacket packet = preparePacket(message);
+ addMessageHints(packet);
+ try {
+ String content;
+ if (message.hasFileOnRemoteHost()) {
+ content = message.getFileParams().url.toString();
+ } else {
+ content = message.getBody();
+ }
+ packet.setBody(otrSession.transformSending(content)[0]);
+ packet.addChild("encryption", "urn:xmpp:eme:0").setAttribute("namespace", "urn:xmpp:otr:0");
+ return packet;
+ } catch (OtrException e) {
+ return null;
+ }
+ }
+
public MessagePacket generateChat(Message message) {
MessagePacket packet = preparePacket(message);
String content;
@@ -233,6 +267,19 @@ public class MessageGenerator extends AbstractGenerator {
return packet;
}
+ public MessagePacket generateOtrError(Jid to, String id, String errorText) {
+ MessagePacket packet = new MessagePacket();
+ packet.setType(MessagePacket.TYPE_ERROR);
+ packet.setAttribute("id", id);
+ packet.setTo(to);
+ Element error = packet.addChild("error");
+ error.setAttribute("code", "406");
+ error.setAttribute("type", "modify");
+ error.addChild("not-acceptable", "urn:ietf:params:xml:ns:xmpp-stanzas");
+ error.addChild("text").setContent("?OTR Error:" + errorText);
+ return packet;
+ }
+
public MessagePacket sessionProposal(final JingleConnectionManager.RtpSessionProposal proposal) {
final MessagePacket packet = new MessagePacket();
packet.setType(MessagePacket.TYPE_CHAT); //we want to carbon copy those
diff --git a/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/src/main/java/eu/siacs/conversations/parser/MessageParser.java
index 2706a55e4..0c9dafcba 100644
--- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java
+++ b/src/main/java/eu/siacs/conversations/parser/MessageParser.java
@@ -1,8 +1,13 @@
package eu.siacs.conversations.parser;
+import android.os.Build;
+import android.text.Html;
import android.util.Log;
import android.util.Pair;
+import net.java.otr4j.session.Session;
+import net.java.otr4j.session.SessionStatus;
+
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
@@ -16,6 +21,7 @@ import java.util.UUID;
import eu.siacs.conversations.Config;
import eu.siacs.conversations.R;
+import eu.siacs.conversations.crypto.OtrService;
import eu.siacs.conversations.crypto.axolotl.AxolotlService;
import eu.siacs.conversations.crypto.axolotl.BrokenSessionException;
import eu.siacs.conversations.crypto.axolotl.NotEncryptedForThisDeviceException;
@@ -28,9 +34,11 @@ import eu.siacs.conversations.entities.Conversation;
import eu.siacs.conversations.entities.Conversational;
import eu.siacs.conversations.entities.Message;
import eu.siacs.conversations.entities.MucOptions;
+import eu.siacs.conversations.entities.Presence;
import eu.siacs.conversations.entities.ReadByMarker;
import eu.siacs.conversations.entities.ReceiptRequest;
import eu.siacs.conversations.entities.RtpSessionStatus;
+import eu.siacs.conversations.entities.ServiceDiscoveryResult;
import eu.siacs.conversations.http.HttpConnectionManager;
import eu.siacs.conversations.services.MessageArchiveService;
import eu.siacs.conversations.services.QuickConversationsService;
@@ -49,6 +57,7 @@ import eu.siacs.conversations.xmpp.pep.Avatar;
import eu.siacs.conversations.xmpp.stanzas.MessagePacket;
public class MessageParser extends AbstractParser implements OnMessagePacketReceived {
+ private static final List CLIENTS_SENDING_HTML_IN_OTR = Arrays.asList("Pidgin", "Adium", "Trillian");
private static final SimpleDateFormat TIME_FORMAT = new SimpleDateFormat("HH:mm:ss", Locale.ENGLISH);
@@ -95,6 +104,30 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
return result != null ? result : fallback;
}
+ private static boolean clientMightSendHtml(Account account, Jid from) {
+ String resource = from.getResource();
+ if (resource == null) {
+ return false;
+ }
+ Presence presence = account.getRoster().getContact(from).getPresences().getPresencesMap().get(resource);
+ ServiceDiscoveryResult disco = presence == null ? null : presence.getServiceDiscoveryResult();
+ if (disco == null) {
+ return false;
+ }
+ return hasIdentityKnowForSendingHtml(disco.getIdentities());
+ }
+
+ private static boolean hasIdentityKnowForSendingHtml(List identities) {
+ for (ServiceDiscoveryResult.Identity identity : identities) {
+ if (identity.getName() != null) {
+ if (CLIENTS_SENDING_HTML_IN_OTR.contains(identity.getName())) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
private boolean extractChatState(Conversation c, final boolean isTypeGroupChat, final MessagePacket packet) {
ChatState state = ChatState.parse(packet);
if (state != null && c != null) {
@@ -126,6 +159,66 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
return false;
}
+ private Message parseOtrChat(String body, Jid from, String id, Conversation conversation) {
+ String presence;
+ if (from.isBareJid()) {
+ presence = "";
+ } else {
+ presence = from.getResource();
+ }
+ if (body.matches("^\\?OTRv\\d{1,2}\\?.*")) {
+ conversation.endOtrIfNeeded();
+ }
+ if (!conversation.hasValidOtrSession()) {
+ conversation.startOtrSession(presence, false);
+ } else {
+ String foreignPresence = conversation.getOtrSession().getSessionID().getUserID();
+ if (!foreignPresence.equals(presence)) {
+ conversation.endOtrIfNeeded();
+ conversation.startOtrSession(presence, false);
+ }
+ }
+ try {
+ conversation.setLastReceivedOtrMessageId(id);
+ Session otrSession = conversation.getOtrSession();
+ body = otrSession.transformReceiving(body);
+ SessionStatus status = otrSession.getSessionStatus();
+ if (body == null && status == SessionStatus.ENCRYPTED) {
+ mXmppConnectionService.onOtrSessionEstablished(conversation);
+ return null;
+ } else if (body == null && status == SessionStatus.FINISHED) {
+ conversation.resetOtrSession();
+ mXmppConnectionService.updateConversationUi();
+ return null;
+ } else if (body == null || (body.isEmpty())) {
+ return null;
+ }
+ if (body.startsWith(CryptoHelper.FILETRANSFER)) {
+ String key = body.substring(CryptoHelper.FILETRANSFER.length());
+ conversation.setSymmetricKey(CryptoHelper.hexToBytes(key));
+ return null;
+ }
+ if (clientMightSendHtml(conversation.getAccount(), from)) {
+ Log.d(Config.LOGTAG, conversation.getAccount().getJid().asBareJid() + ": received OTR message from bad behaving client. escaping HTML…");
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ body = Html.fromHtml(body, Html.FROM_HTML_MODE_LEGACY).toString();
+ } else {
+ body = Html.fromHtml(body).toString();
+ }
+ }
+
+ final OtrService otrService = conversation.getAccount().getOtrService();
+ Message finishedMessage = new Message(conversation, body, Message.ENCRYPTION_OTR, Message.STATUS_RECEIVED);
+ finishedMessage.setFingerprint(otrService.getFingerprint(otrSession.getRemotePublicKey()));
+ conversation.setLastReceivedOtrMessageId(null);
+
+ return finishedMessage;
+ } catch (Exception e) {
+ conversation.resetOtrSession();
+ return null;
+ }
+ }
+
private Message parseAxolotlChat(Element axolotlMessage, Jid from, Conversation conversation, int status, final boolean checkedForDuplicates, boolean postpone) {
final AxolotlService service = conversation.getAccount().getAxolotlService();
final XmppAxolotlMessage xmppAxolotlMessage;
@@ -327,6 +420,11 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
final Jid from = packet.getFrom();
final String id = packet.getId();
if (from != null && id != null) {
+ final Message message = mXmppConnectionService.markMessage(account,
+ from.asBareJid(),
+ packet.getId(),
+ Message.STATUS_SEND_FAILED,
+ extractErrorMessage(packet));
if (id.startsWith(JingleRtpConnection.JINGLE_MESSAGE_PROPOSE_ID_PREFIX)) {
final String sessionId = id.substring(JingleRtpConnection.JINGLE_MESSAGE_PROPOSE_ID_PREFIX.length());
mXmppConnectionService.getJingleConnectionManager()
@@ -335,8 +433,8 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
}
if (id.startsWith(JingleRtpConnection.JINGLE_MESSAGE_PROCEED_ID_PREFIX)) {
final String sessionId = id.substring(JingleRtpConnection.JINGLE_MESSAGE_PROCEED_ID_PREFIX.length());
- final String message = extractErrorMessage(packet);
- mXmppConnectionService.getJingleConnectionManager().failProceed(account, from, sessionId, message);
+ final String errorMessage = extractErrorMessage(packet);
+ mXmppConnectionService.getJingleConnectionManager().failProceed(account, from, sessionId, errorMessage);
return true;
}
mXmppConnectionService.markMessage(account,
@@ -355,6 +453,13 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
}
}
}
+
+ if (message != null) {
+ if (message.getEncryption() == Message.ENCRYPTION_OTR) {
+ Conversation conversation = (Conversation) message.getConversation();
+ conversation.endOtrIfNeeded();
+ }
+ }
}
return true;
}
@@ -368,6 +473,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
}
final MessagePacket packet;
Long timestamp = null;
+ final boolean isForwarded;
boolean isCarbon = false;
String serverMsgId = null;
final Element fin = original.findChild("fin", MessageArchiveService.Version.MAM_0.namespace);
@@ -385,7 +491,9 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
}
timestamp = f.second;
packet = f.first;
+ isForwarded = true;
serverMsgId = result.getAttribute("id");
+
query.incrementMessageCount();
if (handleErrorMessage(account, packet)) {
return;
@@ -403,8 +511,10 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
}
timestamp = f != null ? f.second : null;
isCarbon = f != null;
+ isForwarded = isCarbon;
} else {
packet = original;
+ isForwarded = false;
}
if (timestamp == null) {
@@ -449,6 +559,7 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
Log.e(Config.LOGTAG, account.getJid().asBareJid() + ": received groupchat (" + from + ") message on regular MAM request. skipping");
return;
}
+ boolean isProperlyAddressed = (to != null) && (!to.isBareJid() || account.countPresences() == 0);
boolean isMucStatusMessage = InvalidJid.hasValidFrom(packet) && from.isBareJid() && mucUserElement != null && mucUserElement.hasChild("status");
boolean selfAddressed;
if (packet.fromAccount(account)) {
@@ -547,7 +658,20 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
}
}
final Message message;
- if (pgpEncrypted != null && Config.supportOpenPgp()) {
+ if (body != null && body.content.startsWith("?OTR") && Config.supportOtr()) {
+ if (!isForwarded && !isTypeGroupChat && isProperlyAddressed && !conversationMultiMode) {
+ message = parseOtrChat(body.content, from, remoteMsgId, conversation);
+ if (message == null) {
+ return;
+ }
+ } else {
+ Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": ignoring OTR message from " + from + " isForwarded=" + Boolean.toString(isForwarded) + ", isProperlyAddressed=" + Boolean.valueOf(isProperlyAddressed));
+ message = new Message(conversation, body.content, Message.ENCRYPTION_NONE, status);
+ if (body.count > 1) {
+ message.setBodyLanguage(body.language);
+ }
+ }
+ } else if (pgpEncrypted != null && Config.supportOpenPgp()) {
message = new Message(conversation, pgpEncrypted, Message.ENCRYPTION_PGP, status);
} else if (axolotlEncrypted != null && Config.supportOmemo()) {
Jid origin;
@@ -796,6 +920,13 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece
processMessageReceipts(account, packet, remoteMsgId, query);
}
+ if (message.getStatus() == Message.STATUS_RECEIVED
+ && conversation.getOtrSession() != null
+ && !conversation.getOtrSession().getSessionID().getUserID()
+ .equals(message.getCounterpart().getResource())) {
+ conversation.endOtrIfNeeded();
+ }
+
mXmppConnectionService.databaseBackend.createMessage(message);
final HttpConnectionManager manager = this.mXmppConnectionService.getHttpConnectionManager();
if (message.trusted() && message.treatAsDownloadable() && manager.getAutoAcceptFileSize() > 0) {
diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
index 0c16df6af..bf3f07a35 100644
--- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
+++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java
@@ -61,6 +61,11 @@ import com.google.common.base.Objects;
import com.google.common.base.Optional;
import com.google.common.base.Strings;
+import net.java.otr4j.session.Session;
+import net.java.otr4j.session.SessionID;
+import net.java.otr4j.session.SessionImpl;
+import net.java.otr4j.session.SessionStatus;
+
import org.conscrypt.Conscrypt;
import org.jxmpp.stringprep.libidn.LibIdnXmppStringprep;
import org.openintents.openpgp.IOpenPgpService2;
@@ -171,6 +176,7 @@ import eu.siacs.conversations.xmpp.OnUpdateBlocklist;
import eu.siacs.conversations.xmpp.XmppConnection;
import eu.siacs.conversations.xmpp.chatstate.ChatState;
import eu.siacs.conversations.xmpp.forms.Data;
+import eu.siacs.conversations.xmpp.jid.OtrJidHelper;
import eu.siacs.conversations.xmpp.jingle.AbstractJingleConnection;
import eu.siacs.conversations.xmpp.jingle.JingleConnectionManager;
import eu.siacs.conversations.xmpp.jingle.JingleRtpConnection;
@@ -255,9 +261,18 @@ public class XmppConnectionService extends Service {
Conversation conversation = find(getConversations(), contact);
if (conversation != null) {
if (online) {
+ conversation.endOtrIfNeeded();
if (contact.getPresences().size() == 1) {
sendUnsentMessages(conversation);
}
+ } else {
+ //check if the resource we are haveing a conversation with is still online
+ if (conversation.hasValidOtrSession()) {
+ String otrResource = conversation.getOtrSession().getSessionID().getUserID();
+ if (!(Arrays.asList(contact.getPresences().toResourceArray()).contains(otrResource))) {
+ conversation.endOtrIfNeeded();
+ }
+ }
}
}
};
@@ -447,6 +462,9 @@ public class XmppConnectionService extends Service {
if (conversation.getAccount() == account
&& !pendingJoin
&& !inProgressJoin) {
+ if (!conversation.startOtrIfNeeded()) {
+ Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": couldn't start OTR with " + conversation.getContact().getJid() + " when needed");
+ }
sendUnsentMessages(conversation);
}
}
@@ -1704,6 +1722,12 @@ public class XmppConnectionService extends Service {
}
}
+ if (!resend && message.getEncryption() != Message.ENCRYPTION_OTR) {
+ conversation.endOtrIfNeeded();
+ conversation.findUnsentMessagesWithEncryption(Message.ENCRYPTION_OTR,
+ message1 -> markMessage(message1, Message.STATUS_SEND_FAILED));
+ }
+
final boolean inProgressJoin = isJoinInProgress(conversation);
@@ -1736,6 +1760,30 @@ public class XmppConnectionService extends Service {
packet = mMessageGenerator.generatePgpChat(message);
}
break;
+ case Message.ENCRYPTION_OTR:
+ SessionImpl otrSession = conversation.getOtrSession();
+ if (otrSession != null && otrSession.getSessionStatus() == SessionStatus.ENCRYPTED) {
+ try {
+ message.setCounterpart(OtrJidHelper.fromSessionID(otrSession.getSessionID()));
+ } catch (IllegalArgumentException e) {
+ break;
+ }
+ if (message.needsUploading()) {
+ mJingleConnectionManager.startJingleFileTransfer(message);
+ } else {
+ packet = mMessageGenerator.generateOtrChat(message);
+ }
+ } else if (otrSession == null) {
+ if (message.fixCounterpart()) {
+ conversation.startOtrSession(message.getCounterpart().getResource(), true);
+ } else {
+ Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": could not fix counterpart for OTR message to contact " + message.getCounterpart());
+ break;
+ }
+ } else {
+ Log.d(Config.LOGTAG, account.getJid().asBareJid() + " OTR session with " + message.getContact() + " is in wrong state: " + otrSession.getSessionStatus().toString());
+ }
+ break;
case Message.ENCRYPTION_AXOLOTL:
message.setFingerprint(account.getAxolotlService().getOwnFingerprint());
if (message.needsUploading()) {
@@ -1789,6 +1837,12 @@ public class XmppConnectionService extends Service {
}
}
break;
+ case Message.ENCRYPTION_OTR:
+ if (!conversation.hasValidOtrSession() && message.getCounterpart() != null) {
+ Log.d(Config.LOGTAG, account.getJid().asBareJid() + ": create otr session without starting for " + message.getContact().getJid());
+ conversation.startOtrSession(message.getCounterpart().getResource(), false);
+ }
+ break;
case Message.ENCRYPTION_AXOLOTL:
message.setFingerprint(account.getAxolotlService().getOwnFingerprint());
break;
@@ -3922,6 +3976,12 @@ public class XmppConnectionService extends Service {
if (conversation.getAccount() == account) {
if (conversation.getMode() == Conversation.MODE_MULTI) {
leaveMuc(conversation, true);
+ } else {
+ if (conversation.endOtrIfNeeded()) {
+ Log.d(Config.LOGTAG, account.getJid().asBareJid()
+ + ": ended otr session with "
+ + conversation.getJid());
+ }
}
}
}
@@ -3978,6 +4038,39 @@ public class XmppConnectionService extends Service {
pushContactToServer(contact, preAuth);
}
+ public void onOtrSessionEstablished(Conversation conversation) {
+ final Account account = conversation.getAccount();
+ final Session otrSession = conversation.getOtrSession();
+ Log.d(Config.LOGTAG,
+ account.getJid().asBareJid() + " otr session established with "
+ + conversation.getJid() + "/"
+ + otrSession.getSessionID().getUserID());
+ conversation.findUnsentMessagesWithEncryption(Message.ENCRYPTION_OTR, new Conversation.OnMessageFound() {
+
+ @Override
+ public void onMessageFound(Message message) {
+ SessionID id = otrSession.getSessionID();
+ try {
+ message.setCounterpart(Jid.of(id.getAccountID() + "/" + id.getUserID()));
+ } catch (IllegalArgumentException e) {
+ return;
+ }
+ if (message.needsUploading()) {
+ mJingleConnectionManager.startJingleFileTransfer(message);
+ } else {
+ MessagePacket outPacket = mMessageGenerator.generateOtrChat(message);
+ if (outPacket != null) {
+ mMessageGenerator.addDelay(outPacket, message.getTimeSent());
+ message.setStatus(Message.STATUS_SEND);
+ databaseBackend.updateMessage(message, false);
+ sendMessagePacket(account, outPacket);
+ }
+ }
+ updateConversationUi();
+ }
+ });
+ }
+
public void pushContactToServer(final Contact contact) {
pushContactToServer(contact, null);
}
@@ -4503,6 +4596,7 @@ public class XmppConnectionService extends Service {
return false;
} else {
final Message message = conversation.findSentMessageWithUuid(uuid);
+
if (message != null) {
if (message.getServerMsgId() == null) {
message.setServerMsgId(serverMessageId);
@@ -4805,6 +4899,11 @@ public class XmppConnectionService extends Service {
setMemorizingTrustManager(tm);
}
+ public void syncRosterToDisk(final Account account) {
+ Runnable runnable = () -> databaseBackend.writeRoster(account.getRoster());
+ mDatabaseWriterExecutor.execute(runnable);
+ }
+
public LruCache getBitmapCache() {
return this.mBitmapCache;
}
@@ -5272,10 +5371,14 @@ public class XmppConnectionService extends Service {
}
public boolean verifyFingerprints(Contact contact, List fingerprints) {
+ boolean needsRosterWrite = false;
boolean performedVerification = false;
final AxolotlService axolotlService = contact.getAccount().getAxolotlService();
for (XmppUri.Fingerprint fp : fingerprints) {
- if (fp.type == XmppUri.FingerprintType.OMEMO) {
+ if (fp.type == XmppUri.FingerprintType.OTR) {
+ performedVerification |= contact.addOtrFingerprint(fp.fingerprint);
+ needsRosterWrite |= performedVerification;
+ } else if (fp.type == XmppUri.FingerprintType.OMEMO) {
String fingerprint = "05" + fp.fingerprint.replaceAll("\\s", "");
FingerprintStatus fingerprintStatus = axolotlService.getFingerprintTrust(fingerprint);
if (fingerprintStatus != null) {
@@ -5288,6 +5391,11 @@ public class XmppConnectionService extends Service {
}
}
}
+
+ if (needsRosterWrite) {
+ syncRosterToDisk(contact.getAccount());
+ }
+
return performedVerification;
}
diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java
index 1b6eb44a4..50c870489 100644
--- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java
+++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java
@@ -79,6 +79,8 @@ import androidx.viewpager.widget.PagerAdapter;
import com.google.common.base.Optional;
import com.google.common.collect.ImmutableList;
+import net.java.otr4j.session.SessionStatus;
+
import org.jetbrains.annotations.NotNull;
import java.io.File;
@@ -223,6 +225,14 @@ public class ConversationFragment extends XmppFragment
private ConversationsActivity activity;
private Vibrator vibrator;
private boolean reInitRequiredOnStart = true;
+
+ protected OnClickListener clickToVerify = new OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ activity.verifyOtrSessionDialog(conversation, v);
+ }
+ };
+
@ColorInt
private int primaryColor = -1;
@@ -534,6 +544,20 @@ public class ConversationFragment extends XmppFragment
}
}
};
+
+ private OnClickListener mAnswerSmpClickListener = new OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ Intent intent = new Intent(activity, VerifyOTRActivity.class);
+ intent.setAction(VerifyOTRActivity.ACTION_VERIFY_CONTACT);
+ intent.putExtra(EXTRA_ACCOUNT, conversation.getAccount().getJid().asBareJid().toString());
+ intent.putExtra(VerifyOTRActivity.EXTRA_ACCOUNT, conversation.getAccount().getJid().asBareJid().toString());
+ intent.putExtra("mode", VerifyOTRActivity.MODE_ANSWER_QUESTION);
+ startActivity(intent);
+ activity.overridePendingTransition(R.animator.fade_in, R.animator.fade_out);
+ }
+ };
+
protected OnClickListener clickToDecryptListener =
new OnClickListener() {
@@ -1064,6 +1088,8 @@ public class ConversationFragment extends XmppFragment
message.setUuid(UUID.randomUUID().toString());
}
switch (conversation.getNextEncryption()) {
+ case Message.ENCRYPTION_OTR:
+ sendOtrMessage(message);
case Message.ENCRYPTION_PGP:
sendPgpMessage(message);
break;
@@ -3550,6 +3576,14 @@ public class ConversationFragment extends XmppFragment
}
} else if (account.hasPendingPgpIntent(conversation)) {
showSnackbar(R.string.openpgp_messages_found, R.string.decrypt, clickToDecryptListener);
+ } else if (mode == Conversation.MODE_SINGLE
+ && conversation.smpRequested()) {
+ showSnackbar(R.string.smp_requested, R.string.verify, this.mAnswerSmpClickListener);
+ } else if (mode == Conversation.MODE_SINGLE
+ && conversation.hasValidOtrSession()
+ && (conversation.getOtrSession().getSessionStatus() == SessionStatus.ENCRYPTED)
+ && (!conversation.isOtrFingerprintVerified())) {
+ showSnackbar(R.string.unknown_otr_fingerprint, R.string.verify, clickToVerify);
} else if (connection != null
&& connection.getFeatures().blocking()
&& conversation.countMessages() != 0
@@ -3650,6 +3684,11 @@ public class ConversationFragment extends XmppFragment
new Handler()
.post(
() -> {
+ if (conversation.isInHistoryPart()) {
+ conversation.jumpToLatest();
+ refresh(false);
+ }
+
int size = messageList.size();
this.binding.messagesView.setSelection(size - 1);
});
@@ -3937,6 +3976,17 @@ public class ConversationFragment extends XmppFragment
messageSent();
}
+ protected void sendOtrMessage(final Message message) {
+ final ConversationsActivity activity = (ConversationsActivity) getActivity();
+ final XmppConnectionService xmppService = activity.xmppConnectionService;
+ activity.selectPresence(conversation,
+ () -> {
+ message.setCounterpart(conversation.getNextCounterpart());
+ xmppService.sendMessage(message);
+ messageSent();
+ });
+ }
+
protected void sendPgpMessage(final Message message) {
final XmppConnectionService xmppService = activity.xmppConnectionService;
final Contact contact = message.getConversation().getContact();
diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java
index 79c70d46b..e98fb8f4e 100644
--- a/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java
+++ b/src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java
@@ -63,9 +63,12 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
+import androidx.appcompat.widget.PopupMenu;
import androidx.core.app.ActivityCompat;
import androidx.databinding.DataBindingUtil;
+import net.java.otr4j.session.SessionStatus;
+
import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.google.android.material.navigation.NavigationBarView;
@@ -105,6 +108,7 @@ import eu.siacs.conversations.xml.Namespace;
import eu.siacs.conversations.xmpp.Jid;
import eu.siacs.conversations.xmpp.OnUpdateBlocklist;
import io.michaelrocks.libphonenumber.android.NumberParseException;
+import me.drakeet.support.toast.ToastCompat;
public class ConversationsActivity extends XmppActivity implements OnConversationSelected, OnConversationArchived, OnConversationsListItemUpdated, OnConversationRead, XmppConnectionService.OnAccountUpdate, XmppConnectionService.OnConversationUpdate, XmppConnectionService.OnRosterUpdate, OnUpdateBlocklist, XmppConnectionService.OnShowErrorToast, XmppConnectionService.OnAffiliationChanged {
@@ -812,6 +816,33 @@ public class ConversationsActivity extends XmppActivity implements OnConversatio
}
}
+ public void verifyOtrSessionDialog(final Conversation conversation, View view) {
+ if (!conversation.hasValidOtrSession() || conversation.getOtrSession().getSessionStatus() != SessionStatus.ENCRYPTED) {
+ ToastCompat.makeText(this, R.string.otr_session_not_started, Toast.LENGTH_LONG).show();
+ return;
+ }
+ if (view == null) {
+ return;
+ }
+ PopupMenu popup = new PopupMenu(this, view);
+ popup.inflate(R.menu.verification_choices);
+ popup.setOnMenuItemClickListener(menuItem -> {
+ Intent intent = new Intent(ConversationsActivity.this, VerifyOTRActivity.class);
+ intent.setAction(VerifyOTRActivity.ACTION_VERIFY_CONTACT);
+ intent.putExtra("contact", conversation.getContact().getJid().asBareJid().toString());
+ intent.putExtra(EXTRA_ACCOUNT, conversation.getAccount().getJid().asBareJid().toString());
+ switch (menuItem.getItemId()) {
+ case R.id.ask_question:
+ intent.putExtra("mode", VerifyOTRActivity.MODE_ASK_QUESTION);
+ break;
+ }
+ startActivity(intent);
+ overridePendingTransition(R.animator.fade_in, R.animator.fade_out);
+ return true;
+ });
+ popup.show();
+ }
+
@Override
public void onConversationArchived(Conversation conversation) {
if (performRedirectIfNecessary(conversation, false)) {
diff --git a/src/main/java/eu/siacs/conversations/ui/SendLogActivity.java b/src/main/java/eu/siacs/conversations/ui/SendLogActivity.java
index 8dabc77cd..ec537d703 100644
--- a/src/main/java/eu/siacs/conversations/ui/SendLogActivity.java
+++ b/src/main/java/eu/siacs/conversations/ui/SendLogActivity.java
@@ -265,7 +265,6 @@ public class SendLogActivity extends ActionBarActivity {
log.insert(0, mAdditonalInfo);
}
- android.util.Log.e("35fd", log.toString());
writer.write(log.toString());
}
catch (IOException e){
diff --git a/src/main/java/eu/siacs/conversations/ui/VerifyOTRActivity.java b/src/main/java/eu/siacs/conversations/ui/VerifyOTRActivity.java
new file mode 100644
index 000000000..2eb69e5e2
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/ui/VerifyOTRActivity.java
@@ -0,0 +1,450 @@
+package eu.siacs.conversations.ui;
+
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.Menu;
+import android.view.View;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.app.AlertDialog;
+
+import net.java.otr4j.OtrException;
+import net.java.otr4j.session.Session;
+
+import eu.siacs.conversations.Config;
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.entities.Account;
+import eu.siacs.conversations.entities.Contact;
+import eu.siacs.conversations.entities.Conversation;
+import eu.siacs.conversations.services.XmppConnectionService;
+import eu.siacs.conversations.utils.CryptoHelper;
+import eu.siacs.conversations.utils.XmppUri;
+import eu.siacs.conversations.xmpp.Jid;
+import me.drakeet.support.toast.ToastCompat;
+
+public class VerifyOTRActivity extends XmppActivity implements XmppConnectionService.OnConversationUpdate {
+
+ public static final String ACTION_VERIFY_CONTACT = "verify_contact";
+ public static final int MODE_SCAN_FINGERPRINT = -0x0502;
+ public static final int MODE_ASK_QUESTION = 0x0503;
+ public static final int MODE_ANSWER_QUESTION = 0x0504;
+ public static final int MODE_MANUAL_VERIFICATION = 0x0505;
+
+ private LinearLayout mManualVerificationArea;
+ private LinearLayout mSmpVerificationArea;
+ private TextView mRemoteFingerprint;
+ private TextView mYourFingerprint;
+ private TextView mVerificationExplain;
+ private TextView mStatusMessage;
+ private TextView mSharedSecretHint;
+ private EditText mSharedSecretHintEditable;
+ private EditText mSharedSecretSecret;
+ private Button mLeftButton;
+ private Button mRightButton;
+ private Account mAccount;
+ private Conversation mConversation;
+ private int mode = MODE_MANUAL_VERIFICATION;
+ private XmppUri mPendingUri = null;
+
+ private DialogInterface.OnClickListener mVerifyFingerprintListener = new DialogInterface.OnClickListener() {
+
+ @Override
+ public void onClick(DialogInterface dialogInterface, int click) {
+ mConversation.verifyOtrFingerprint();
+ xmppConnectionService.syncRosterToDisk(mConversation.getAccount());
+ ToastCompat.makeText(VerifyOTRActivity.this, R.string.verified, Toast.LENGTH_SHORT).show();
+ finish();
+ }
+ };
+
+ private View.OnClickListener mCreateSharedSecretListener = new View.OnClickListener() {
+ @Override
+ public void onClick(final View view) {
+ if (isAccountOnline()) {
+ final String question = mSharedSecretHintEditable.getText().toString();
+ final String secret = mSharedSecretSecret.getText().toString();
+ if (question.trim().isEmpty()) {
+ mSharedSecretHintEditable.requestFocus();
+ mSharedSecretHintEditable.setError(getString(R.string.shared_secret_hint_should_not_be_empty));
+ } else if (secret.trim().isEmpty()) {
+ mSharedSecretSecret.requestFocus();
+ mSharedSecretSecret.setError(getString(R.string.shared_secret_can_not_be_empty));
+ } else {
+ mSharedSecretSecret.setError(null);
+ mSharedSecretHintEditable.setError(null);
+ initSmp(question, secret);
+ updateView();
+ }
+ }
+ }
+ };
+ private View.OnClickListener mCancelSharedSecretListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ if (isAccountOnline()) {
+ abortSmp();
+ updateView();
+ }
+ }
+ };
+ private View.OnClickListener mRespondSharedSecretListener = new View.OnClickListener() {
+
+ @Override
+ public void onClick(View view) {
+ if (isAccountOnline()) {
+ final String question = mSharedSecretHintEditable.getText().toString();
+ final String secret = mSharedSecretSecret.getText().toString();
+ respondSmp(question, secret);
+ updateView();
+ }
+ }
+ };
+ private View.OnClickListener mRetrySharedSecretListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ mConversation.smp().status = Conversation.Smp.STATUS_NONE;
+ mConversation.smp().hint = null;
+ mConversation.smp().secret = null;
+ updateView();
+ }
+ };
+ private View.OnClickListener mFinishListener = new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ mConversation.smp().status = Conversation.Smp.STATUS_NONE;
+ finish();
+ }
+ };
+
+ protected boolean initSmp(final String question, final String secret) {
+ final Session session = mConversation.getOtrSession();
+ if (session != null) {
+ try {
+ session.initSmp(question, secret);
+ mConversation.smp().status = Conversation.Smp.STATUS_WE_REQUESTED;
+ mConversation.smp().secret = secret;
+ mConversation.smp().hint = question;
+ return true;
+ } catch (OtrException e) {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ }
+
+ protected boolean abortSmp() {
+ final Session session = mConversation.getOtrSession();
+ if (session != null) {
+ try {
+ session.abortSmp();
+ mConversation.smp().status = Conversation.Smp.STATUS_NONE;
+ mConversation.smp().hint = null;
+ mConversation.smp().secret = null;
+ return true;
+ } catch (OtrException e) {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ }
+
+ protected boolean respondSmp(final String question, final String secret) {
+ final Session session = mConversation.getOtrSession();
+ if (session != null) {
+ try {
+ session.respondSmp(question, secret);
+ return true;
+ } catch (OtrException e) {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ }
+
+ protected boolean verifyWithUri(XmppUri uri) {
+ Contact contact = mConversation.getContact();
+ if (this.mConversation.getContact().getJid().equals(uri.getJid()) && uri.hasFingerprints()) {
+ xmppConnectionService.verifyFingerprints(contact, uri.getFingerprints());
+ ToastCompat.makeText(this, R.string.verified, Toast.LENGTH_SHORT).show();
+ updateView();
+ return true;
+ } else {
+ ToastCompat.makeText(this, R.string.could_not_verify_fingerprint, Toast.LENGTH_SHORT).show();
+ return false;
+ }
+ }
+
+ protected boolean isAccountOnline() {
+ if (this.mAccount.getStatus() != Account.State.ONLINE) {
+ ToastCompat.makeText(this, R.string.not_connected_try_again, Toast.LENGTH_SHORT).show();
+ return false;
+ } else {
+ return true;
+ }
+ }
+
+ protected boolean handleIntent(Intent intent) {
+ if (intent != null && intent.getAction().equals(ACTION_VERIFY_CONTACT)) {
+ this.mAccount = extractAccount(intent);
+ if (this.mAccount == null) {
+ return false;
+ }
+ try {
+ this.mConversation = this.xmppConnectionService.find(this.mAccount, Jid.of(intent.getExtras().getString("contact")), null);
+ if (this.mConversation == null) {
+ return false;
+ }
+ } catch (final IllegalArgumentException ignored) {
+ ignored.printStackTrace();
+ return false;
+ } catch (Exception e) {
+ e.printStackTrace();
+ return false;
+ }
+ this.mode = intent.getIntExtra("mode", MODE_MANUAL_VERIFICATION);
+ // todo scan OTR fingerprint
+ if (this.mode == MODE_SCAN_FINGERPRINT) {
+ Log.d(Config.LOGTAG, "Scan OTR fingerprint is not implemented in this version");
+ //new IntentIntegrator(this).initiateScan();
+ return false;
+ }
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent intent) {
+ // todo onActivityResult for OTR scan
+ Log.d(Config.LOGTAG, "Scan OTR fingerprint result is not implemented in this version");
+ /*if ((requestCode & 0xFFFF) == IntentIntegrator.REQUEST_CODE) {
+ IntentResult scanResult = IntentIntegrator.parseActivityResult(requestCode, resultCode, intent);
+ if (scanResult != null && scanResult.getFormatName() != null) {
+ String data = scanResult.getContents();
+ XmppUri uri = new XmppUri(data);
+ if (xmppConnectionServiceBound) {
+ verifyWithUri(uri);
+ finish();
+ } else {
+ this.mPendingUri = uri;
+ }
+ } else {
+ finish();
+ }
+ }*/
+ super.onActivityResult(requestCode, requestCode, intent);
+ }
+
+ @Override
+ protected void onBackendConnected() {
+ if (handleIntent(getIntent())) {
+ updateView();
+ } else if (mPendingUri != null) {
+ verifyWithUri(mPendingUri);
+ finish();
+ mPendingUri = null;
+ }
+ setIntent(null);
+ }
+
+ protected void updateView() {
+ if (this.mConversation != null && this.mConversation.hasValidOtrSession()) {
+ final ActionBar actionBar = getSupportActionBar();
+ this.mVerificationExplain.setText(R.string.no_otr_session_found);
+ invalidateOptionsMenu();
+ switch (this.mode) {
+ case MODE_ASK_QUESTION:
+ if (actionBar != null) {
+ actionBar.setTitle(R.string.ask_question);
+ }
+ this.updateViewAskQuestion();
+ break;
+ case MODE_ANSWER_QUESTION:
+ if (actionBar != null) {
+ actionBar.setTitle(R.string.smp_requested);
+ }
+ this.updateViewAnswerQuestion();
+ break;
+ case MODE_MANUAL_VERIFICATION:
+ default:
+ if (actionBar != null) {
+ actionBar.setTitle(R.string.manually_verify);
+ }
+ this.updateViewManualVerification();
+ break;
+ }
+ } else {
+ this.mManualVerificationArea.setVisibility(View.GONE);
+ this.mSmpVerificationArea.setVisibility(View.GONE);
+ }
+ }
+
+ protected void updateViewManualVerification() {
+ this.mVerificationExplain.setText(R.string.manual_verification_explanation);
+ this.mManualVerificationArea.setVisibility(View.VISIBLE);
+ this.mSmpVerificationArea.setVisibility(View.GONE);
+ this.mYourFingerprint.setText(CryptoHelper.prettifyFingerprint(this.mAccount.getOtrFingerprint()));
+ this.mRemoteFingerprint.setText(CryptoHelper.prettifyFingerprint(this.mConversation.getOtrFingerprint()));
+ if (this.mConversation.isOtrFingerprintVerified()) {
+ deactivateButton(this.mRightButton, R.string.verified);
+ activateButton(this.mLeftButton, R.string.cancel, this.mFinishListener);
+ } else {
+ activateButton(this.mLeftButton, R.string.cancel, this.mFinishListener);
+ activateButton(this.mRightButton, R.string.verify, new View.OnClickListener() {
+ @Override
+ public void onClick(View view) {
+ showManuallyVerifyDialog();
+ }
+ });
+ }
+ }
+
+ protected void updateViewAskQuestion() {
+ this.mManualVerificationArea.setVisibility(View.GONE);
+ this.mSmpVerificationArea.setVisibility(View.VISIBLE);
+ this.mVerificationExplain.setText(R.string.smp_explain_question);
+ final int smpStatus = this.mConversation.smp().status;
+ switch (smpStatus) {
+ case Conversation.Smp.STATUS_WE_REQUESTED:
+ this.mStatusMessage.setVisibility(View.GONE);
+ this.mSharedSecretHintEditable.setVisibility(View.VISIBLE);
+ this.mSharedSecretSecret.setVisibility(View.VISIBLE);
+ this.mSharedSecretHintEditable.setText(this.mConversation.smp().hint);
+ this.mSharedSecretSecret.setText(this.mConversation.smp().secret);
+ this.activateButton(this.mLeftButton, R.string.cancel, this.mCancelSharedSecretListener);
+ this.deactivateButton(this.mRightButton, R.string.in_progress);
+ break;
+ case Conversation.Smp.STATUS_FAILED:
+ this.mStatusMessage.setVisibility(View.GONE);
+ this.mSharedSecretHintEditable.setVisibility(View.VISIBLE);
+ this.mSharedSecretSecret.setVisibility(View.VISIBLE);
+ this.mSharedSecretSecret.requestFocus();
+ this.mSharedSecretSecret.setError(getString(R.string.secrets_do_not_match));
+ this.deactivateButton(this.mLeftButton, R.string.cancel);
+ this.activateButton(this.mRightButton, R.string.try_again, this.mRetrySharedSecretListener);
+ break;
+ case Conversation.Smp.STATUS_VERIFIED:
+ this.mSharedSecretHintEditable.setText("");
+ this.mSharedSecretHintEditable.setVisibility(View.GONE);
+ this.mSharedSecretSecret.setText("");
+ this.mSharedSecretSecret.setVisibility(View.GONE);
+ this.mStatusMessage.setVisibility(View.VISIBLE);
+ this.deactivateButton(this.mLeftButton, R.string.cancel);
+ this.activateButton(this.mRightButton, R.string.finish, this.mFinishListener);
+ break;
+ default:
+ this.mStatusMessage.setVisibility(View.GONE);
+ this.mSharedSecretHintEditable.setVisibility(View.VISIBLE);
+ this.mSharedSecretSecret.setVisibility(View.VISIBLE);
+ this.activateButton(this.mLeftButton, R.string.cancel, this.mFinishListener);
+ this.activateButton(this.mRightButton, R.string.ask_question, this.mCreateSharedSecretListener);
+ break;
+ }
+ }
+
+ protected void updateViewAnswerQuestion() {
+ this.mManualVerificationArea.setVisibility(View.GONE);
+ this.mSmpVerificationArea.setVisibility(View.VISIBLE);
+ this.mVerificationExplain.setText(R.string.smp_explain_answer);
+ this.mSharedSecretHintEditable.setVisibility(View.GONE);
+ this.mSharedSecretHint.setVisibility(View.VISIBLE);
+ this.deactivateButton(this.mLeftButton, R.string.cancel);
+ final int smpStatus = this.mConversation.smp().status;
+ switch (smpStatus) {
+ case Conversation.Smp.STATUS_CONTACT_REQUESTED:
+ this.mStatusMessage.setVisibility(View.GONE);
+ this.mSharedSecretHint.setText(this.mConversation.smp().hint);
+ this.activateButton(this.mRightButton, R.string.respond, this.mRespondSharedSecretListener);
+ break;
+ case Conversation.Smp.STATUS_VERIFIED:
+ this.mSharedSecretHintEditable.setText("");
+ this.mSharedSecretHintEditable.setVisibility(View.GONE);
+ this.mSharedSecretHint.setVisibility(View.GONE);
+ this.mSharedSecretSecret.setText("");
+ this.mSharedSecretSecret.setVisibility(View.GONE);
+ this.mStatusMessage.setVisibility(View.VISIBLE);
+ this.activateButton(this.mRightButton, R.string.finish, this.mFinishListener);
+ break;
+ case Conversation.Smp.STATUS_FAILED:
+ default:
+ this.mSharedSecretSecret.requestFocus();
+ this.mSharedSecretSecret.setError(getString(R.string.secrets_do_not_match));
+ this.activateButton(this.mRightButton, R.string.finish, this.mFinishListener);
+ break;
+ }
+ }
+
+ protected void activateButton(Button button, int text, View.OnClickListener listener) {
+ button.setEnabled(true);
+ button.setText(text);
+ button.setOnClickListener(listener);
+ }
+
+ protected void deactivateButton(Button button, int text) {
+ button.setEnabled(false);
+ button.setText(text);
+ button.setOnClickListener(null);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_verify_otr);
+ this.mRemoteFingerprint = findViewById(R.id.remote_fingerprint);
+ this.mYourFingerprint = findViewById(R.id.your_fingerprint);
+ this.mLeftButton = findViewById(R.id.left_button);
+ this.mRightButton = findViewById(R.id.right_button);
+ this.mVerificationExplain = findViewById(R.id.verification_explanation);
+ this.mStatusMessage = findViewById(R.id.status_message);
+ this.mSharedSecretSecret = findViewById(R.id.shared_secret_secret);
+ this.mSharedSecretHintEditable = findViewById(R.id.shared_secret_hint_editable);
+ this.mSharedSecretHint = findViewById(R.id.shared_secret_hint);
+ this.mManualVerificationArea = findViewById(R.id.manual_verification_area);
+ this.mSmpVerificationArea = findViewById(R.id.smp_verification_area);
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(final Menu menu) {
+ super.onCreateOptionsMenu(menu);
+ getMenuInflater().inflate(R.menu.verify_otr, menu);
+ return true;
+ }
+
+ private void showManuallyVerifyDialog() {
+ final AlertDialog.Builder builder = new AlertDialog.Builder(this);
+ builder.setTitle(R.string.manually_verify);
+ builder.setMessage(R.string.are_you_sure_verify_fingerprint);
+ builder.setNegativeButton(R.string.cancel, null);
+ builder.setPositiveButton(R.string.verify, mVerifyFingerprintListener);
+ builder.create().show();
+ }
+
+ @Override
+ protected String getShareableUri() {
+ if (mAccount != null) {
+ return mAccount.getShareableUri();
+ } else {
+ return "";
+ }
+ }
+
+ public void onConversationUpdate() {
+ refreshUi();
+ }
+
+ @Override
+ protected void refreshUiReal() {
+ updateView();
+ }
+}
diff --git a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java
index 47ce3be01..3e1f878a9 100644
--- a/src/main/java/eu/siacs/conversations/ui/XmppActivity.java
+++ b/src/main/java/eu/siacs/conversations/ui/XmppActivity.java
@@ -56,6 +56,8 @@ import androidx.databinding.DataBindingUtil;
import com.google.common.base.Strings;
+import net.java.otr4j.session.SessionID;
+
import java.io.IOException;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
@@ -446,7 +448,18 @@ public abstract class XmppActivity extends ActionBarActivity {
public void selectPresence(final Conversation conversation, final PresenceSelector.OnPresenceSelected listener) {
final Contact contact = conversation.getContact();
- if (contact.showInRoster() || contact.isSelf()) {
+
+ if (conversation.hasValidOtrSession()) {
+ SessionID id = conversation.getOtrSession().getSessionID();
+ Jid jid;
+ try {
+ jid = Jid.of(id.getAccountID() + "/" + id.getUserID());
+ } catch (IllegalArgumentException e) {
+ jid = null;
+ }
+ conversation.setNextCounterpart(jid);
+ listener.onPresenceSelected();
+ } else if (contact.showInRoster() || contact.isSelf()) {
final Presences presences = contact.getPresences();
if (presences.size() == 0) {
if (contact.isSelf()) {
diff --git a/src/main/java/eu/siacs/conversations/utils/CryptoHelper.java b/src/main/java/eu/siacs/conversations/utils/CryptoHelper.java
index 85ea2bb86..59399fbca 100644
--- a/src/main/java/eu/siacs/conversations/utils/CryptoHelper.java
+++ b/src/main/java/eu/siacs/conversations/utils/CryptoHelper.java
@@ -41,6 +41,7 @@ public final class CryptoHelper {
private static final int PW_LENGTH = 12;
private static final char[] VOWELS = "aeiou".toCharArray();
private static final char[] CONSONANTS = "bcfghjklmnpqrstvwxyz".toCharArray();
+ public static final String FILETRANSFER = "?FILETRANSFERv1:";
private final static char[] hexArray = "0123456789abcdef".toCharArray();
public static String bytesToHex(byte[] bytes) {
diff --git a/src/main/java/eu/siacs/conversations/utils/UIHelper.java b/src/main/java/eu/siacs/conversations/utils/UIHelper.java
index f684c5f46..d1b863082 100644
--- a/src/main/java/eu/siacs/conversations/utils/UIHelper.java
+++ b/src/main/java/eu/siacs/conversations/utils/UIHelper.java
@@ -609,6 +609,8 @@ public class UIHelper {
} else {
return context.getString(R.string.send_message_to_x, conversation.getName());
}
+ case Message.ENCRYPTION_OTR:
+ return context.getString(R.string.send_otr_message);
case Message.ENCRYPTION_AXOLOTL:
AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
if (axolotlService != null && axolotlService.trustedSessionVerified(conversation)) {
diff --git a/src/main/java/eu/siacs/conversations/utils/XmppUri.java b/src/main/java/eu/siacs/conversations/utils/XmppUri.java
index 6c3075be9..6f7b8481c 100644
--- a/src/main/java/eu/siacs/conversations/utils/XmppUri.java
+++ b/src/main/java/eu/siacs/conversations/utils/XmppUri.java
@@ -29,6 +29,7 @@ public class XmppUri {
public static final String PARAMETER_PRE_AUTH = "preauth";
public static final String PARAMETER_IBR = "ibr";
private static final String OMEMO_URI_PARAM = "omemo-sid-";
+ private static final String OTR_URI_PARAM = "otr-fingerprint";
protected Uri uri;
protected String jid;
private List fingerprints = new ArrayList<>();
@@ -111,6 +112,8 @@ public class XmppUri {
if (type == XmppUri.FingerprintType.OMEMO) {
builder.append(XmppUri.OMEMO_URI_PARAM);
builder.append(fingerprints.get(i).deviceId);
+ } else if (type == XmppUri.FingerprintType.OTR) {
+ builder.append(XmppUri.OTR_URI_PARAM);
}
builder.append('=');
builder.append(fingerprints.get(i).fingerprint);
@@ -241,7 +244,8 @@ public class XmppUri {
}
public enum FingerprintType {
- OMEMO
+ OMEMO,
+ OTR
}
public static class Fingerprint {
@@ -249,6 +253,10 @@ public class XmppUri {
public final String fingerprint;
final int deviceId;
+ public Fingerprint(FingerprintType type, String fingerprint) {
+ this(type, fingerprint, 0);
+ }
+
public Fingerprint(FingerprintType type, String fingerprint, int deviceId) {
this.type = type;
this.fingerprint = fingerprint;
diff --git a/src/main/java/eu/siacs/conversations/xmpp/jid/OtrJidHelper.java b/src/main/java/eu/siacs/conversations/xmpp/jid/OtrJidHelper.java
new file mode 100644
index 000000000..09c4dec53
--- /dev/null
+++ b/src/main/java/eu/siacs/conversations/xmpp/jid/OtrJidHelper.java
@@ -0,0 +1,17 @@
+
+package eu.siacs.conversations.xmpp.jid;
+
+import net.java.otr4j.session.SessionID;
+
+import eu.siacs.conversations.xmpp.Jid;
+
+public final class OtrJidHelper {
+
+ public static Jid fromSessionID(final SessionID id) throws IllegalArgumentException {
+ if (id.getUserID().isEmpty()) {
+ return Jid.of(id.getAccountID());
+ } else {
+ return Jid.of(id.getAccountID() + "/" + id.getUserID());
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/res/layout/activity_verify_otr.xml b/src/main/res/layout/activity_verify_otr.xml
new file mode 100644
index 000000000..5c15dd3e6
--- /dev/null
+++ b/src/main/res/layout/activity_verify_otr.xml
@@ -0,0 +1,148 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/res/menu/verification_choices.xml b/src/main/res/menu/verification_choices.xml
new file mode 100644
index 000000000..cad8dee9a
--- /dev/null
+++ b/src/main/res/menu/verification_choices.xml
@@ -0,0 +1,12 @@
+
+
\ No newline at end of file
diff --git a/src/main/res/menu/verify_otr.xml b/src/main/res/menu/verify_otr.xml
new file mode 100644
index 000000000..b6dd79610
--- /dev/null
+++ b/src/main/res/menu/verify_otr.xml
@@ -0,0 +1,15 @@
+
+
\ No newline at end of file
diff --git a/src/main/res/values/about.xml b/src/main/res/values/about.xml
index b22ae317e..286ccc84d 100644
--- a/src/main/res/values/about.xml
+++ b/src/main/res/values/about.xml
@@ -60,6 +60,7 @@
\n\nhttps://github.com/zxing/zxing\n(Apache License, Version 2.0)
\n\nhttps://github.com/osmdroid/osmdroid\n(Apache License, Version 2.0)
\n\nhttps://git.singpolyma.net/cheogram-android\n(GPLv3)
+ \n\nhttps://github.com/jitsi/otr4j\n(LGPL-3.0)
\n\n\nMaps
\n\nMaps by Open Street Map (https://www.openstreetmap.org). Copyright restrictions may apply.
diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml
index d56415813..8cd41d4bc 100644
--- a/src/main/res/values/strings.xml
+++ b/src/main/res/values/strings.xml
@@ -1142,4 +1142,38 @@
Struck
Whisper
Lime
+
+ Write message…
+ Unknown OTR fingerprint
+ OTR fingerprint
+ OTR fingerprint of message
+ OTR fingerprint copied to clipboard!
+ Verify OTR
+ No valid OTR session has been found!
+ Are you sure that you want to verify your contacts OTR fingerprint?
+ Copy OTR fingerprint to clipboard
+ Send a message to start an encrypted chat
+ Enable OTR encryption for message encryption. OTR is still highly unstable, please only use it if you know what you do.
+ Enable OTR encryption (BETA)
+ The support of OTR encryption is in the beta mode. Click read more to get more information. A link in a browser will open.
+ Verified!
+ Contact requested SMP verification
+ If you and your contact have a secret in common that no one else knows (like an inside joke or simply what you had for lunch the last time you met) you can use that secret to verify each other’s fingerprints.\n\nYou provide a hint or a question for your contact who will respond with a case-sensitive answer.
+ Your contact would like to verify your fingerprint by challenging you with a shared secret. Your contact provided the following hint or question for that secret.
+ Your hint should not be empty
+ Your shared secret can not be empty
+ Carefully compare the fingerprint shown below with the fingerprint of your contact.\nYou can use any trusted form of communication like an encrypted e-mail or a telephone call to exchange those.
+ Could not verify fingerprint
+ Manually verify
+ Secrets do not match
+ Ask question
+ Verify
+ In progress
+ Respond
+ Failed
+ Finish
+ Your fingerprint
+ Remote Fingerprint
+ Hint or Question
+ Shared Secret